This workflow corresponds to n8n.io template #16110 — we link there as the canonical source.
This workflow follows the HTTP Request → Itemlists recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"id": "5JXySNBNzG9I6loe",
"meta": {
"templateCredsSetupCompleted": true
},
"name": "Optimize inactive M365 premium licenses with Microsoft Graph and Teams",
"tags": [],
"nodes": [
{
"id": "ea86763b-e3e8-4fe5-9467-64406f0743b4",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1072,
2352
],
"parameters": {
"width": 480,
"height": 640,
"content": "## M365 License Cost Optimization\n\n### How it works\n\nThis workflow runs monthly to identify Microsoft 365 users holding premium licenses who appear inactive and are not in an exemption group. It retrieves subscribed SKUs and exemption members from Microsoft Graph, builds a downgrade plan, and either exits with a Teams notification or processes each downgrade with API pacing and optional dry-run behavior. It records successes or errors during the loop, then sends an IT manager summary when processing is complete.\n\n### Setup steps\n\n- Configure Microsoft Graph OAuth credentials with permissions to read subscribed SKUs, users, groups, and license assignments, plus permissions to update user license assignments when not running in dry-run mode.\n- Set the workflow configuration values, including premiumSkuPartNumbers, baselineSkuId, inactivityThresholdDays, newHireThresholdDays, exemption group identifier, and any dry-run flag used by the code nodes.\n- Configure Microsoft Teams credentials or incoming webhook/channel settings for no-action notifications, license error alerts, and the final IT manager summary.\n- Verify the monthly schedule, test first in dry-run mode, and confirm the selected baseline SKU and premium SKU part numbers match your tenant.\n\n### Customization\n\nAdjust the inactivity and new-hire thresholds, premium SKU list, exemption group, batch size, wait duration, and Teams message templates to match your license governance policy."
},
"typeVersion": 1
},
{
"id": "e2377dc8-36ac-446d-876c-9908290db184",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
-528,
2672
],
"parameters": {
"color": 7,
"width": 640,
"height": 336,
"content": "## Schedule and configuration\n\nStarts the monthly run, sets optimization parameters, and validates that required configuration values are present before any Microsoft Graph calls are made."
},
"typeVersion": 1
},
{
"id": "bb2c5aa5-de62-4465-9625-744adad6fb08",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
144,
2672
],
"parameters": {
"color": 7,
"width": 864,
"height": 336,
"content": "## Load SKUs and exemptions\n\nFetches subscribed Microsoft 365 SKUs, builds a premium SKU lookup, retrieves exemption group members, and aggregates the exemption list for later filtering."
},
"typeVersion": 1
},
{
"id": "0d9bc689-6c72-41b9-a1c1-5f383e5acaf2",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
1040,
2672
],
"parameters": {
"color": 7,
"width": 640,
"height": 336,
"content": "## Find downgrade candidates\n\nCollects all licensed users, filters for inactive premium-license holders while respecting thresholds and exemptions, then decides whether any users need downgrading."
},
"typeVersion": 1
},
{
"id": "892b4e7f-3bc4-45eb-8105-d516542242c5",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"position": [
1728,
2672
],
"parameters": {
"color": 7,
"width": 416,
"height": 336,
"content": "## No-action completion\n\nHandles the branch where no downgrade candidates are found by notifying Microsoft Teams and ending the workflow without license changes."
},
"typeVersion": 1
},
{
"id": "8db3e63f-2d86-40d7-9bcd-6aea5d878db1",
"name": "Sticky Note5",
"type": "n8n-nodes-base.stickyNote",
"position": [
1728,
2304
],
"parameters": {
"color": 7,
"width": 416,
"height": 320,
"content": "## Prepare downgrade loop\n\nTransforms the candidate list into a downgrade plan and starts the split-in-batches loop that processes one downgrade item at a time."
},
"typeVersion": 1
},
{
"id": "340aeef6-0130-49e7-940f-617be25b058d",
"name": "Sticky Note6",
"type": "n8n-nodes-base.stickyNote",
"position": [
2208,
2112
],
"parameters": {
"color": 7,
"width": 416,
"height": 336,
"content": "## Pace and branch mode\n\nAdds a wait between API operations to avoid rate-limit issues, then branches between dry-run passthrough and real license downgrade execution."
},
"typeVersion": 1
},
{
"id": "46f49b05-df98-434f-9a65-58a5e923a7c6",
"name": "Sticky Note7",
"type": "n8n-nodes-base.stickyNote",
"position": [
2656,
2112
],
"parameters": {
"color": 7,
"width": 432,
"height": 336,
"content": "## Execute license downgrade\n\nPerforms the Microsoft Graph license assignment update for a user and checks whether the downgrade request succeeded."
},
"typeVersion": 1
},
{
"id": "d5b9bea2-36ee-47fb-a248-7594be85c190",
"name": "Sticky Note8",
"type": "n8n-nodes-base.stickyNote",
"position": [
3136,
2112
],
"parameters": {
"color": 7,
"width": 640,
"height": 592,
"content": "## Record iteration result\n\nNormalizes dry-run results, records successful downgrade outcomes, sends Teams alerts for license errors, and routes the item back to the batch loop for the next user."
},
"typeVersion": 1
},
{
"id": "ab858e63-8440-4862-8fed-df9ab54ac69d",
"name": "Sticky Note9",
"type": "n8n-nodes-base.stickyNote",
"position": [
2208,
2608
],
"parameters": {
"color": 7,
"width": 864,
"height": 304,
"content": "## Summarize and finish\n\nCollects all loop results, builds a summary report, sends the final IT manager notification in Microsoft Teams, and marks the workflow complete."
},
"typeVersion": 1
},
{
"id": "f7ab3bb5-91d7-42c8-992d-e1f70f8c5b25",
"name": "Monthly Schedule Trigger at 6am",
"type": "n8n-nodes-base.scheduleTrigger",
"position": [
-480,
2832
],
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 6 1 * *"
}
]
}
},
"typeVersion": 1.2
},
{
"id": "5fe59b21-c736-4da8-aa94-e9cf78fd352f",
"name": "Set Configuration Parameters",
"type": "n8n-nodes-base.set",
"position": [
-256,
2832
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "c05-01",
"name": "premiumSkuPartNumbers",
"type": "array",
"value": [
"ENTERPRISEPREMIUM",
"SPE_E5",
"M365_E5",
"ENTERPRISEPACK"
]
},
{
"id": "c05-02",
"name": "baselineSkuId",
"type": "string",
"value": "YOUR_BASELINE_SKU_ID"
},
{
"id": "c05-03",
"name": "inactivityThresholdDays",
"type": "number",
"value": 45
},
{
"id": "c05-08",
"name": "newHireThresholdDays",
"type": "number",
"value": 90
},
{
"id": "c05-04",
"name": "teamsTeamId",
"type": "string",
"value": "YOUR_TEAMS_TEAM_ID"
},
{
"id": "c05-05",
"name": "teamsChannelId",
"type": "string",
"value": "YOUR_TEAMS_CHANNEL_ID"
},
{
"id": "c05-06",
"name": "cutoffDate",
"type": "string",
"value": "={{ $now.minus({days: 45}).toISO() }}"
},
{
"id": "c05-09",
"name": "newHireWindowStart",
"type": "string",
"value": "={{ $now.minus({days: 90}).toISO() }}"
},
{
"id": "c05-07",
"name": "exemptionGroupId",
"type": "string",
"value": "YOUR_ENTRA_SECURITY_GROUP_OBJECT_ID"
},
{
"id": "c05-10",
"name": "dryRunMode",
"type": "boolean",
"value": false
}
]
}
},
"typeVersion": 3.4
},
{
"id": "ba9ef487-2965-4317-a4cd-567cdad6910b",
"name": "Validate Configuration",
"type": "n8n-nodes-base.code",
"position": [
-32,
2832
],
"parameters": {
"jsCode": "const cfg = $input.first().json;\nconst placeholders = [\n { key: 'baselineSkuId', value: cfg.baselineSkuId },\n { key: 'teamsTeamId', value: cfg.teamsTeamId },\n { key: 'teamsChannelId', value: cfg.teamsChannelId },\n { key: 'exemptionGroupId', value: cfg.exemptionGroupId }\n];\nconst unfilled = placeholders.filter(p => typeof p.value === 'string' && p.value.startsWith('YOUR_'));\nif (unfilled.length > 0) {\n throw new Error(`Config placeholders not replaced: ${unfilled.map(p => p.key).join(', ')}. Update Config node before activating.`);\n}\nreturn $input.all();"
},
"typeVersion": 2
},
{
"id": "9d654313-e7f7-4643-b978-ad069adb1b79",
"name": "Fetch Subscribed SKUs",
"type": "n8n-nodes-base.httpRequest",
"onError": "continueRegularOutput",
"maxTries": 3,
"position": [
192,
2832
],
"parameters": {
"url": "https://graph.microsoft.com/v1.0/subscribedSkus?$select=skuId,skuPartNumber,consumedUnits",
"options": {},
"authentication": "predefinedCredentialType",
"nodeCredentialType": "oAuth2Api"
},
"retryOnFail": true,
"typeVersion": 4.2,
"continueOnFail": true,
"waitBetweenTries": 3000
},
{
"id": "87963f0d-cadb-4430-942b-2585fe9c24e8",
"name": "Generate Premium SKU Map",
"type": "n8n-nodes-base.code",
"position": [
416,
2832
],
"parameters": {
"jsCode": "const result = $input.first().json;\nif (result.error) {\n throw new Error(`Get Subscribed SKUs failed: ${result.error.message || JSON.stringify(result.error)}`);\n}\nconst skus = result.value || [];\nconst premiumSkuPartNumbers = $('Set Configuration Parameters').item.json.premiumSkuPartNumbers || [];\nconst premiumSkuIds = skus\n .filter(s => premiumSkuPartNumbers.includes(s.skuPartNumber))\n .map(s => s.skuId);\nif (premiumSkuIds.length === 0) {\n throw new Error(`No SKUs matched premiumSkuPartNumbers [${premiumSkuPartNumbers.join(', ')}]. Check your tenant subscriptions or Config values.`);\n}\nreturn [{ json: { premiumSkuIds } }];"
},
"typeVersion": 2
},
{
"id": "c71ef76f-7b9a-41a6-a8a6-2142bfb79f65",
"name": "Retrieve Exemption Group Members",
"type": "n8n-nodes-base.httpRequest",
"onError": "continueRegularOutput",
"maxTries": 3,
"position": [
640,
2832
],
"parameters": {
"url": "=https://graph.microsoft.com/v1.0/groups/{{ $('Set Configuration Parameters').item.json.exemptionGroupId }}/members?$select=id&$top=999",
"options": {},
"authentication": "predefinedCredentialType",
"nodeCredentialType": "oAuth2Api"
},
"retryOnFail": true,
"typeVersion": 4.2,
"continueOnFail": true,
"alwaysOutputData": true,
"waitBetweenTries": 3000
},
{
"id": "75793e5b-2c07-4de1-b072-a769a0cb0cbe",
"name": "Compile Exemption List",
"type": "n8n-nodes-base.itemLists",
"position": [
864,
2832
],
"parameters": {
"options": {},
"aggregate": "aggregateAllItemData",
"operation": "concatenateItems",
"destinationFieldName": "exemptions"
},
"typeVersion": 3
},
{
"id": "af46a55c-ab8b-41d5-a356-15869cb006c2",
"name": "Fetch All Licensed Users",
"type": "n8n-nodes-base.code",
"position": [
1088,
2832
],
"parameters": {
"jsCode": "let url = \"https://graph.microsoft.com/v1.0/users?$filter=accountEnabled eq true and userType eq 'Member' and assignedLicenses/$count ne 0&$select=id,displayName,userPrincipalName,assignedLicenses,signInActivity,createdDateTime&$top=999&$count=true\";\nlet allUsers = [];\nlet pageCount = 0;\n\nwhile (url) {\n const response = await this.helpers.httpRequestWithAuthentication.call(\n this,\n 'oAuth2Api',\n {\n method: 'GET',\n url,\n headers: { 'ConsistencyLevel': 'eventual' },\n returnFullResponse: false,\n json: true\n }\n );\n if (response.error) {\n throw new Error(`Get All Licensed Users page ${pageCount + 1} failed: ${response.error.message || JSON.stringify(response.error)}`);\n }\n allUsers = allUsers.concat(response.value || []);\n url = response['@odata.nextLink'] || null;\n pageCount++;\n}\n\nreturn [{ json: { value: allUsers, totalFetched: allUsers.length, pages: pageCount } }];"
},
"typeVersion": 2
},
{
"id": "e9125d3e-504f-4a74-b419-e68f5a60312c",
"name": "Filter Inactive Premium Members",
"type": "n8n-nodes-base.code",
"position": [
1312,
2832
],
"parameters": {
"jsCode": "const users = $input.first().json.value || [];\nconst premiumSkuIds = $('Generate Premium SKU Map').item.json.premiumSkuIds || [];\nconst cutoffDate = new Date($('Set Configuration Parameters').item.json.cutoffDate);\nconst newHireWindowStart = new Date($('Set Configuration Parameters').item.json.newHireWindowStart);\nconst baselineSkuId = $('Set Configuration Parameters').item.json.baselineSkuId;\n\nconst exemptionData = $('Compile Exemption List').item.json.exemptions || [];\nconst exemptedUserIds = new Set(\n exemptionData.flatMap(page => page.value ? page.value.map(m => m.id) : [])\n);\n\nconst inactiveUsers = users.filter(user => {\n if (!user.id) return false;\n if (exemptedUserIds.has(user.id)) return false;\n const hasPremium = (user.assignedLicenses || []).some(lic => premiumSkuIds.includes(lic.skuId));\n if (!hasPremium) return false;\n const signIn = user.signInActivity;\n if (!signIn || !signIn.lastSignInDateTime) {\n if (user.createdDateTime && new Date(user.createdDateTime) >= newHireWindowStart) return false;\n return true;\n }\n return new Date(signIn.lastSignInDateTime) < cutoffDate;\n});\n\nconst downgradePlan = inactiveUsers.map(user => {\n const premiumLicensesToRemove = (user.assignedLicenses || [])\n .filter(lic => premiumSkuIds.includes(lic.skuId))\n .map(lic => lic.skuId);\n return {\n userId: user.id,\n displayName: user.displayName,\n userPrincipalName: user.userPrincipalName,\n lastSignIn: user.signInActivity?.lastSignInDateTime ?? 'Never',\n premiumLicensesToRemove,\n baselineSkuId\n };\n});\n\nreturn [{\n json: {\n downgradePlan,\n downgradeCount: downgradePlan.length,\n exemptionsFound: exemptedUserIds.size,\n totalScanned: users.length\n }\n}];"
},
"typeVersion": 2
},
{
"id": "fefea0b2-5aa4-4b48-9b54-feeaad81405f",
"name": "Check Downgradable Users",
"type": "n8n-nodes-base.if",
"position": [
1536,
2832
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 1,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "cond-dg",
"operator": {
"type": "number",
"operation": "gt"
},
"leftValue": "={{ $json.downgradeCount }}",
"rightValue": 0
}
]
}
},
"typeVersion": 2.2
},
{
"id": "5bb05c18-90db-4ad8-8f96-a2cf4bc678ab",
"name": "Notify No Downgrades Required",
"type": "n8n-nodes-base.microsoftTeams",
"position": [
1776,
2848
],
"parameters": {
"teamId": "={{ $('Set Configuration Parameters').item.json.teamsTeamId }}",
"message": "=\u2705 **Monthly License Audit \u2014 No Action Required**\n\n**Date:** {{ $now.toFormat('yyyy-MM-dd') }}\n**Users Scanned:** {{ $('Filter Inactive Premium Members').item.json.totalScanned }}\n**Exemptions Active:** {{ $('Filter Inactive Premium Members').item.json.exemptionsFound }}\n**Flagged for Downgrade:** 0\n\nAll premium license holders have been active within the last {{ $('Set Configuration Parameters').item.json.inactivityThresholdDays }} days, or are protected by the exclusion group.",
"options": {},
"resource": "channelMessage",
"channelId": "={{ $('Set Configuration Parameters').item.json.teamsChannelId }}"
},
"typeVersion": 2,
"continueOnFail": true
},
{
"id": "718a42d1-1aa3-41d3-b2ab-a5eb5bf96094",
"name": "Complete No Action Needed",
"type": "n8n-nodes-base.noOp",
"position": [
2000,
2848
],
"parameters": {},
"typeVersion": 1
},
{
"id": "a7676f0b-ae45-4393-9689-cafccc366448",
"name": "Divide Downgrade Plan",
"type": "n8n-nodes-base.code",
"position": [
1776,
2464
],
"parameters": {
"jsCode": "return $('Filter Inactive Premium Members').item.json.downgradePlan.map(user => ({ json: user }));"
},
"typeVersion": 2
},
{
"id": "83e7a99d-f651-427d-8104-0b055f978ce5",
"name": "Batch Process Downgrades",
"type": "n8n-nodes-base.splitInBatches",
"position": [
2000,
2464
],
"parameters": {
"options": {}
},
"typeVersion": 3
},
{
"id": "3545275a-ad1f-4c91-9e6b-da460a6e49fc",
"name": "Wait 0.5 Seconds",
"type": "n8n-nodes-base.wait",
"position": [
2256,
2288
],
"parameters": {
"amount": 0.5
},
"typeVersion": 1
},
{
"id": "04f5640d-b0c6-425f-a0d3-b1dfb5124b5d",
"name": "Check Dry Run Mode",
"type": "n8n-nodes-base.if",
"position": [
2480,
2288
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 1,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "cond-dry",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
},
"leftValue": "={{ $('Set Configuration Parameters').item.json.dryRunMode }}",
"rightValue": true
}
]
}
},
"typeVersion": 2.2
},
{
"id": "ff61f85e-33bf-486c-b940-d30981b68882",
"name": "Pass on Dry Run Mode",
"type": "n8n-nodes-base.code",
"position": [
3184,
2544
],
"parameters": {
"jsCode": "return [{ json: { ...$input.first().json, dryRun: true } }];"
},
"typeVersion": 2
},
{
"id": "e7f55d43-f8cb-45cd-af02-9dd00883a812",
"name": "Perform License Downgrade",
"type": "n8n-nodes-base.httpRequest",
"onError": "continueRegularOutput",
"position": [
2704,
2288
],
"parameters": {
"url": "=https://graph.microsoft.com/v1.0/users/{{ $json.userId }}/assignLicense",
"method": "POST",
"options": {},
"jsonBody": "={{ { addLicenses: [{ skuId: $json.baselineSkuId }], removeLicenses: $json.premiumLicensesToRemove } }}",
"sendBody": true,
"specifyBody": "json",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "oAuth2Api"
},
"retryOnFail": false,
"typeVersion": 4.2,
"continueOnFail": true
},
{
"id": "a8cc7625-30cf-4c1c-bb65-a856945a8db4",
"name": "Verify Downgrade Success",
"type": "n8n-nodes-base.if",
"position": [
2928,
2288
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 1,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "e7c45d15",
"operator": {
"type": "string",
"operation": "empty",
"singleValue": true
},
"leftValue": "={{ $json.error }}",
"rightValue": ""
}
]
}
},
"typeVersion": 2.2
},
{
"id": "769634d6-8a0a-4655-976f-418e26a75961",
"name": "Log Downgrade Success",
"type": "n8n-nodes-base.code",
"position": [
3408,
2272
],
"parameters": {
"jsCode": "const isDryRun = !!$input.first().json.dryRun;\n\n// Live path: $input.first().json is the Graph assignLicense response, which does not\n// contain user fields. We source the original user object from the Wait (API Pacing)\n// node, which holds it immediately before the Graph call.\n// NOTE: If you rename 'Wait (API Pacing)', update this reference to match.\n// Dry-run path: Dry Run Passthrough already carries all user fields on $input.\nconst user = isDryRun\n ? $input.first().json\n : $('Wait 0.5 Seconds').item.json;\n\nreturn [{ json: { ...user, dryRun: isDryRun, status: 'success' } }];"
},
"typeVersion": 2
},
{
"id": "74140106-75dc-4e9e-920e-351a853d07de",
"name": "Alert License Error",
"type": "n8n-nodes-base.microsoftTeams",
"position": [
3408,
2464
],
"parameters": {
"teamId": "={{ $('Set Configuration Parameters').item.json.teamsTeamId }}",
"message": "=\u274c **License Downgrade Error**\n\n**Time:** {{ $now.toISO() }}\n**User:** {{ $('Wait 0.5 Seconds').item.json.userPrincipalName }}\n**Error:** {{ $json.error ? $json.error.message : 'Unknown error during license operation' }}\n\nReview Execute License Downgrade node in n8n execution log.",
"options": {},
"resource": "channelMessage",
"channelId": "={{ $('Set Configuration Parameters').item.json.teamsChannelId }}"
},
"typeVersion": 2,
"continueOnFail": true
},
{
"id": "2521252e-6873-426b-934f-cd608e33ae1f",
"name": "Prepare Next Downgrade",
"type": "n8n-nodes-base.noOp",
"position": [
3632,
2368
],
"parameters": {},
"typeVersion": 1
},
{
"id": "cbc9f5e7-545d-4e2d-9f88-591bbd94743e",
"name": "Gather Downgrade Results",
"type": "n8n-nodes-base.itemLists",
"position": [
2256,
2736
],
"parameters": {
"options": {},
"aggregate": "aggregateAllItemData",
"operation": "concatenateItems",
"destinationFieldName": "processedUsers"
},
"typeVersion": 3
},
{
"id": "eaa5594a-2ef1-4012-88ee-a49d57345fd7",
"name": "Create Summary Report",
"type": "n8n-nodes-base.code",
"position": [
2480,
2736
],
"parameters": {
"jsCode": "const isDryRun = $('Set Configuration Parameters').item.json.dryRunMode;\nconst raw = $input.first().json.processedUsers || [];\n\n// aggregateAllItemData in n8n v3 wraps items as { json: {...} } in some versions.\n// Normalise to plain objects regardless.\n// Next Downgrade merges both Record Success and License Error Alert paths, so\n// processedUsers contains Teams API response objects from the error path as well.\n// These have no status field and are discarded by the filter below.\nconst allResults = raw.map(item => item.json ?? item);\n\nconst successes = allResults.filter(item => item.status === 'success');\n\nconst lines = successes.map((u, i) => {\n const tag = u.dryRun ? ' [DRY RUN]' : '';\n return `${i + 1}. ${u.displayName} (${u.userPrincipalName}) \u2014 Last Sign-In: ${u.lastSignIn}${tag}`;\n}).join('\\n');\n\nreturn [{\n json: {\n summary: lines || 'No successful downgrades processed in this run.',\n count: successes.length,\n isDryRun: !!isDryRun,\n teamsTeamId: $('Set Configuration Parameters').item.json.teamsTeamId,\n teamsChannelId: $('Set Configuration Parameters').item.json.teamsChannelId\n }\n}];"
},
"typeVersion": 2
},
{
"id": "a8c34015-8067-474d-b9db-b8ff4addd275",
"name": "Deliver Summary to IT Manager",
"type": "n8n-nodes-base.microsoftTeams",
"position": [
2704,
2736
],
"parameters": {
"teamId": "={{ $json.teamsTeamId }}",
"message": "=\ud83d\udcca **Monthly License Optimization Report{{ $json.isDryRun ? ' \u2014 DRY RUN (no changes made)' : '' }}**\n\n**Date:** {{ $now.toFormat('yyyy-MM-dd') }}\n**Accounts Downgraded:** {{ $json.count }}\n**Inactivity Threshold:** {{ $('Set Configuration Parameters').item.json.inactivityThresholdDays }} days\n**Users Scanned:** {{ $('Filter Inactive Premium Members').item.json.totalScanned }}\n**Exemptions Active:** {{ $('Filter Inactive Premium Members').item.json.exemptionsFound }}\n\n**Modified Accounts:**\n{{ $json.summary }}\n\n{{ $json.isDryRun ? '\u26a0\ufe0f Dry run mode was active. No licenses were modified.' : 'All successfully modified accounts have had premium licenses removed and baseline licenses assigned.' }}",
"options": {},
"resource": "channelMessage",
"channelId": "={{ $json.teamsChannelId }}"
},
"typeVersion": 2,
"continueOnFail": true
},
{
"id": "c9a8ab04-ea87-44ef-97d0-3bf92938c7be",
"name": "Finalize Workflow",
"type": "n8n-nodes-base.noOp",
"position": [
2928,
2736
],
"parameters": {},
"typeVersion": 1
}
],
"active": false,
"settings": {
"binaryMode": "separate",
"executionOrder": "v1"
},
"versionId": "38cbc40d-dbfa-4546-aab0-0a058bf7fdb4",
"connections": {
"Wait 0.5 Seconds": {
"main": [
[
{
"node": "Check Dry Run Mode",
"type": "main",
"index": 0
}
]
]
},
"Check Dry Run Mode": {
"main": [
[
{
"node": "Pass on Dry Run Mode",
"type": "main",
"index": 0
}
],
[
{
"node": "Perform License Downgrade",
"type": "main",
"index": 0
}
]
]
},
"Alert License Error": {
"main": [
[
{
"node": "Prepare Next Downgrade",
"type": "main",
"index": 0
}
]
]
},
"Pass on Dry Run Mode": {
"main": [
[
{
"node": "Log Downgrade Success",
"type": "main",
"index": 0
}
]
]
},
"Create Summary Report": {
"main": [
[
{
"node": "Deliver Summary to IT Manager",
"type": "main",
"index": 0
}
]
]
},
"Divide Downgrade Plan": {
"main": [
[
{
"node": "Batch Process Downgrades",
"type": "main",
"index": 0
}
]
]
},
"Fetch Subscribed SKUs": {
"main": [
[
{
"node": "Generate Premium SKU Map",
"type": "main",
"index": 0
}
]
]
},
"Log Downgrade Success": {
"main": [
[
{
"node": "Prepare Next Downgrade",
"type": "main",
"index": 0
}
]
]
},
"Compile Exemption List": {
"main": [
[
{
"node": "Fetch All Licensed Users",
"type": "main",
"index": 0
}
]
]
},
"Prepare Next Downgrade": {
"main": [
[
{
"node": "Batch Process Downgrades",
"type": "main",
"index": 0
}
]
]
},
"Validate Configuration": {
"main": [
[
{
"node": "Fetch Subscribed SKUs",
"type": "main",
"index": 0
}
]
]
},
"Batch Process Downgrades": {
"main": [
[
{
"node": "Wait 0.5 Seconds",
"type": "main",
"index": 0
}
],
[
{
"node": "Gather Downgrade Results",
"type": "main",
"index": 0
}
]
]
},
"Check Downgradable Users": {
"main": [
[
{
"node": "Divide Downgrade Plan",
"type": "main",
"index": 0
}
],
[
{
"node": "Notify No Downgrades Required",
"type": "main",
"index": 0
}
]
]
},
"Fetch All Licensed Users": {
"main": [
[
{
"node": "Filter Inactive Premium Members",
"type": "main",
"index": 0
}
]
]
},
"Gather Downgrade Results": {
"main": [
[
{
"node": "Create Summary Report",
"type": "main",
"index": 0
}
]
]
},
"Generate Premium SKU Map": {
"main": [
[
{
"node": "Retrieve Exemption Group Members",
"type": "main",
"index": 0
}
]
]
},
"Verify Downgrade Success": {
"main": [
[
{
"node": "Log Downgrade Success",
"type": "main",
"index": 0
}
],
[
{
"node": "Alert License Error",
"type": "main",
"index": 0
}
]
]
},
"Perform License Downgrade": {
"main": [
[
{
"node": "Verify Downgrade Success",
"type": "main",
"index": 0
}
]
]
},
"Set Configuration Parameters": {
"main": [
[
{
"node": "Validate Configuration",
"type": "main",
"index": 0
}
]
]
},
"Deliver Summary to IT Manager": {
"main": [
[
{
"node": "Finalize Workflow",
"type": "main",
"index": 0
}
]
]
},
"Notify No Downgrades Required": {
"main": [
[
{
"node": "Complete No Action Needed",
"type": "main",
"index": 0
}
]
]
},
"Filter Inactive Premium Members": {
"main": [
[
{
"node": "Check Downgradable Users",
"type": "main",
"index": 0
}
]
]
},
"Monthly Schedule Trigger at 6am": {
"main": [
[
{
"node": "Set Configuration Parameters",
"type": "main",
"index": 0
}
]
]
},
"Retrieve Exemption Group Members": {
"main": [
[
{
"node": "Compile Exemption List",
"type": "main",
"index": 0
}
]
]
}
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow runs monthly to audit Microsoft 365 licensing via Microsoft Graph, identifies inactive users holding premium SKUs (excluding an exemption group and recent hires), optionally downgrades them to a baseline license, and posts a results summary to a Microsoft Teams…
Source: https://n8n.io/workflows/16110/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
This workflow runs every 30 minutes to scan VIP Microsoft Outlook calendars, look up attendee details in Microsoft Entra ID via Microsoft Graph, search SharePoint for related documents, and send a pre
Wait Code. Uses httpRequest, htmlExtract, itemLists, scheduleTrigger. Scheduled trigger; 15 nodes.
This workflow syncs Outlook Calendar events to a Notion database. The Outlook Calendar event must be within a specific time frame (default of within next year) for the workflow to pick up the event. T
sync-outlook-calendar-events-to-notion. Uses notion, itemLists, httpRequest. Scheduled trigger; 9 nodes.
YOUR_ID 4. Uses gmail, googleDrive, googleSheets, httpRequest. Scheduled trigger; 53 nodes.