Copy Outlook Calendar Events with Logic Apps or Power Automate

Automatically sync multiple Outlook Calendars to one ‘main’ Calendar

Working as a consultant sometimes means having multiple mail addresses and calendars to manage. Up until now, I added Calendar events from my client manually to my ‘main’ calendar, just to keep it up to date for my colleagues and avoid two meetings at the same time.

After multiple attempts of trying to fix this through the outlook client, I decided to try a different approach. Logic Apps and Power Automate have out of the box connectors for Office 365 Outlook.

What looked like a very simple solution at first, escalated pretty quickly. Besides adding a new event, I wanted to handle updates and deletes as well.

I’ll give a detailed description of the Logic App I created. All steps can be reproduced in Power Automate as well.

Update 1-11-2021 – At the end of the blog, you can find the ARM Template of the Locig App!

Logic App Setup

Trigger to start the process

To trigger the app, I used the Office 365 connector ‘When an event is added, updated, or deleted (V3)‘. This connector makes use of the Microsoft Graph API. The trigger is authenticated with the credentials of the account from which you want to send the events (source calendar). This new version of the connector is triggered almost instantly when an event gets added, updated, or deleted in the connected Calendar. The type of event is displayed in the ‘Action Type‘ field.

Trigger Settings

After the trigger we initialize a variable, we need this in a later stadium for updated events.

Initialize Variable Settings

Switch on Action Type

Depending on the ‘Action Type’ we define different actions. For this, I use a ‘Switch‘ action. Let’s start with the simple one, ‘added‘.

Switch on Action Type

Added logic

If the ‘Action Type’ equals ‘added’ I want to add a new event to my ‘main’ outlook account. The connector used is ‘Create event (V4)‘. This one is authenticated with the ‘target’ account. The information I use to create the event is derived from the trigger. There are two adjustments I made compared to the original appointment.

1. The subject; I added something to recognize the source calendar. In this case, a short name to recognize the client: ‘ABC:’.

2. The body; I am not interested in the original body of the appointment. The only thing I add here is the ‘Id‘ field. I need this later on when updating existing appointments.

Create Event and Set Parameters

If you like you can set additional parameters, like reminders and how it is displayed in the Calendar.


Updated

If the ‘Action Type’ is ‘updated’ I want to change the existing event in the target calendar, when it is not there, I want to add it. This is where it gets a little more complicated, but not undoable.

Get Events

The first thing to do is to check the target calendar if we can find the event that needs to be updated. We do this with the ‘Get events (V4)‘ action. The action uses an OData protocol to get the events from your account.

My intuition was to filter the results on the ‘Id’-field we added to the body of the event. But I kept getting errors, probably because the body is Html. To solve this we need two steps:

  1. Get a list of events by Subject
  2. Loop through the list to find the right event

To filter the results of the ‘Get events’ we can use the following expression:

contains(Subject, '@{triggerBody()?['subject']}')

Get Events and Filter on Subject
Loop through all Events

Now we will try to find the right event by looking for the ‘Id‘ field in the body of the event.

Loop the Result Set for the Id

If we find the right event, we delete it and create it again with the new information. After that, we set the variable to ‘true’.

Delete the Event, Recreate it and Set the Variable

In the delete action, it is important to use the ‘Id’ that we get from the ‘Get Events’ action because this corresponds with the previously created event.

Add if Event wasn’t found

If for any reason the event is not found, I want to add those too. For this, we initialized the variable at the top level of the Logic App. If the event was not found in the previous step, the variable will still be ‘false’. Meaning that we are missing this event in our target calendar. Thus we create it in the same way we did in the first added action

If Variable ‘UpdatedEventExists’ is ‘false’ add the Event to the Calendar

Deleted

When an event is deleted in the source calendar, I want to remove it from the target calendar as well. Basically, I do the same steps as when the ‘Action Type’ is ‘updated’. There is one downside, the trigger returns very little information about the event. There is only the ‘Id’ of the event we can search for in the body of the appointments. This means I can only filter on the short name I added in the subject to filter the results.

Get Events with filter on Subject, Look for the ‘Id’ and Delete Event

Not filtering the query in the ‘Get Events’ action can take significantly more time and therefore is more expensive in the end.

Wrap Up

That’s it! We can now add appointments from one calendar to another, update them if they change, and delete them when needed!

I admit it is not a perfect solution. Recurring appointments from the source calendar are not handled in the best way.

And I would really like to add a category to the created events. The Graph API docs tell me that it should be possible, but I didn’t manage to get it working with these Office 365 Connectors.

Let me know if you see any improvements!

UPDATE 1st of November 2021

I got multiple requests for sharing the code, so I thought it would be nice to share the code with all of you! Below is a gist of the ARM Template of the Logic App. You can import this into an empty logic app by pasting it into the code view. Then reconfigure the connections to the Outlook accounts and you should be good to go!

{
"definition": {
"$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#",
"actions": {
"Initialize_variable": {
"inputs": {
"variables": [
{
"name": "UpdatedEventExists",
"type": "boolean",
"value": "@false"
}
]
},
"runAfter": {},
"type": "InitializeVariable"
},
"Switch": {
"cases": {
"Case": {
"actions": {
"Create_event_(V4)": {
"inputs": {
"body": {
"body": "<p>@{triggerBody()?['id']}</p>",
"end": "@triggerBody()?['end']",
"isReminderOn": false,
"showAs": "busy",
"start": "@triggerBody()?['start']",
"subject": "Vf:@{triggerBody()?['subject']}",
"timeZone": "@triggerBody()?['timeZone']"
},
"host": {
"connection": {
"name": "@parameters('$connections')['office365_1']['connectionId']"
}
},
"method": "post",
"path": "/datasets/calendars/v4/tables/@{encodeURIComponent(encodeURIComponent('<CalendarID>'))}/items"
},
"runAfter": {},
"type": "ApiConnection"
}
},
"case": "added"
},
"Case_2": {
"actions": {
"Condition_2": {
"actions": {
"Create_event_(V4)_3": {
"inputs": {
"body": {
"body": "<p>@{triggerBody()?['id']}</p>",
"end": "@triggerBody()?['end']",
"isReminderOn": false,
"showAs": "busy",
"start": "@triggerBody()?['start']",
"subject": "Vf:@{triggerBody()?['subject']}",
"timeZone": "@triggerBody()?['timeZone']"
},
"host": {
"connection": {
"name": "@parameters('$connections')['office365_1']['connectionId']"
}
},
"method": "post",
"path": "/datasets/calendars/v4/tables/@{encodeURIComponent(encodeURIComponent('<CalendarID>'))}/items"
},
"runAfter": {},
"type": "ApiConnection"
}
},
"expression": {
"and": [
{
"equals": [
"@variables('UpdatedEventExists')",
"@false"
]
}
]
},
"runAfter": {
"For_each": [
"Succeeded"
]
},
"type": "If"
},
"For_each": {
"actions": {
"Condition": {
"actions": {
"Create_event_(V4)_2": {
"inputs": {
"body": {
"body": "<p>@{triggerBody()?['id']}<br>\n</p>",
"end": "@triggerBody()?['end']",
"isReminderOn": false,
"showAs": "busy",
"start": "@triggerBody()?['start']",
"subject": "Vf:@{triggerBody()?['subject']}",
"timeZone": "@triggerBody()?['timeZone']"
},
"host": {
"connection": {
"name": "@parameters('$connections')['office365_1']['connectionId']"
}
},
"method": "post",
"path": "/datasets/calendars/v4/tables/@{encodeURIComponent(encodeURIComponent('<CalendarID>'))}/items"
},
"runAfter": {
"Delete_event_(V2)": [
"Succeeded"
]
},
"type": "ApiConnection"
},
"Delete_event_(V2)": {
"inputs": {
"host": {
"connection": {
"name": "@parameters('$connections')['office365_1']['connectionId']"
}
},
"method": "delete",
"path": "/codeless/v1.0/me/calendars/@{encodeURIComponent(encodeURIComponent('<CalendarID>'))}/events/@{encodeURIComponent(encodeURIComponent(items('For_each')?['id']))}"
},
"runAfter": {},
"type": "ApiConnection"
},
"Set_variable": {
"inputs": {
"name": "UpdatedEventExists",
"value": "@true"
},
"runAfter": {
"Create_event_(V4)_2": [
"Succeeded"
]
},
"type": "SetVariable"
}
},
"expression": {
"and": [
{
"contains": [
"@items('For_each')?['body']",
"@triggerBody()?['id']"
]
}
]
},
"runAfter": {},
"type": "If"
}
},
"foreach": "@body('Get_events_(V4)')?['value']",
"runAfter": {
"Get_events_(V4)": [
"Succeeded"
]
},
"runtimeConfiguration": {
"concurrency": {
"repetitions": 1
}
},
"type": "Foreach"
},
"Get_events_(V4)": {
"inputs": {
"host": {
"connection": {
"name": "@parameters('$connections')['office365_1']['connectionId']"
}
},
"method": "get",
"path": "/datasets/calendars/v4/tables/@{encodeURIComponent(encodeURIComponent('<CalendarID>'))}/items",
"queries": {
"$filter": "contains(Subject, '@{triggerBody()?['subject']}')"
}
},
"runAfter": {},
"type": "ApiConnection"
}
},
"case": "updated"
},
"Case_3": {
"actions": {
"For_each_2": {
"actions": {
"Condition_3": {
"actions": {
"Delete_event_(V2)_2": {
"inputs": {
"host": {
"connection": {
"name": "@parameters('$connections')['office365_1']['connectionId']"
}
},
"method": "delete",
"path": "/codeless/v1.0/me/calendars/@{encodeURIComponent(encodeURIComponent('<CalendarID>'))}/events/@{encodeURIComponent(encodeURIComponent(items('For_each_2')?['id']))}"
},
"runAfter": {},
"type": "ApiConnection"
}
},
"expression": {
"and": [
{
"contains": [
"@items('For_each_2')?['body']",
"@triggerBody()?['id']"
]
}
]
},
"runAfter": {},
"type": "If"
}
},
"foreach": "@body('Get_events_(V4)_2')?['value']",
"runAfter": {
"Get_events_(V4)_2": [
"Succeeded"
]
},
"type": "Foreach"
},
"Get_events_(V4)_2": {
"inputs": {
"host": {
"connection": {
"name": "@parameters('$connections')['office365_1']['connectionId']"
}
},
"method": "get",
"path": "/datasets/calendars/v4/tables/@{encodeURIComponent(encodeURIComponent('<CalendarID>'))}/items",
"queries": {
"$filter": "contains(Subject, 'Vf:')"
}
},
"runAfter": {},
"type": "ApiConnection"
}
},
"case": "deleted"
}
},
"default": {
"actions": {}
},
"expression": "@triggerBody()?['ActionType']",
"runAfter": {
"Initialize_variable": [
"Succeeded"
]
},
"type": "Switch"
}
},
"contentVersion": "1.0.0.0",
"outputs": {},
"parameters": {
"$connections": {
"defaultValue": {},
"type": "Object"
}
},
"triggers": {
"When_an_event_is_added,_updated_or_deleted_(V3)": {
"conditions": [],
"inputs": {
"fetch": {
"method": "get",
"pathTemplate": {
"parameters": {
"table": "@{encodeURIComponent(encodeURIComponent('<CalendarID>'))}"
},
"template": "/datasets/calendars/v3/tables/{table}/onchangeditems"
},
"queries": {
"incomingDays": 30,
"pastDays": 1
}
},
"host": {
"connection": {
"name": "@parameters('$connections')['office365']['connectionId']"
}
},
"subscribe": {
"body": {
"NotificationUrl": "@{listCallbackUrl()}"
},
"method": "post",
"pathTemplate": {
"parameters": {
"table": "@{encodeURIComponent(encodeURIComponent('<CalendarID>'))}"
},
"template": "/{table}/GraphEventSubscriptionPoke/$subscriptions"
},
"queries": {
"incomingDays": 30,
"pastDays": 1
}
}
},
"runtimeConfiguration": {
"concurrency": {
"runs": 50
}
},
"splitOn": "@triggerBody()?['value']",
"type": "ApiConnectionNotification"
}
}
},
"parameters": {
"$connections": {
"value": {
"office365": {
"connectionId": "/subscriptions/<subscriptionID>/resourceGroups/<resourceGroupName>/providers/Microsoft.Web/connections/office365",
"connectionName": "office365",
"id": "/subscriptions/<subscriptionID>/providers/Microsoft.Web/locations/westeurope/managedApis/office365"
},
"office365_1": {
"connectionId": "/subscriptions/<subscriptionID>/resourceGroups/<resourceGroupName>/providers/Microsoft.Web/connections/office365-1",
"connectionName": "office365-1",
"id": "/subscriptions/<subscriptionID>/providers/Microsoft.Web/locations/westeurope/managedApis/office365"
}
}
}
}
}

References

Office 365 Outlook – Connectors | Microsoft Docs

9 thoughts on “Copy Outlook Calendar Events with Logic Apps or Power Automate

  1. I am having trouble with the Delete and Update portion of this flow. When I delete, nothing happens. When I update it creates a new event. I am so close to this working. I wonder if you could just share a copy of the flow you have via email? This would be so helpful in my organization. Thank you for the write up.

    Like

    1. Hey, sure. I will email you the json code of the Logic App!

      Like

  2. adamplatten@hotmail.co.uk 1 November 2021 — 14:49

    Hey Arthur,

    This is extremely useful. However, I am the same as J Frink. When I update it creates a new event. Could you also share a copy of the flow by email?

    Thanks,

    Like

    1. Hello Adam,

      Thanks for letting me know.

      I added a Gist with the Azure Resource Management Template of the Logic App. You could paste this in the code view in a Logic App editor!

      Like

  3. Hello and thanks for sharing and great job!
    I’m looking to make this happen at an organization level in a transparent way for our users, do you know if this is possible? Thanks for your help.

    Like

  4. Hi, thanks for sharing this. I been trying to find a solution for this for years. I work as a management consultant and would like to keep my company e-mail up to date with events from all the accounts I have with clients. I do not have any developer experience, however, I managed to create the flow in Logic Apps by inserting your code. Now I do not understand where to put my e-mail accounts, passwords, calendar IDs in the code. Is there an easy way to understand this, or should I seek assistance?

    Like

    1. Hi Frederik, thanks!. You should be able to do this from the Logic Apps Designer in the Azure Portal. If you click the first Outlook Activity, ‘When an event is added’, you can change the connection to you email account and afterwards in the parameters, the calendarID is needed!

      Like

  5. After testing this in Power Automate, I can say that as the logic is now, it will not work due to an issue with Microsoft. When using the “When an event is added, updated or deleted” trigger and the Action Type switch, whenever an event is Updated the output will set both Added and Updated to True. So the switch will run the Added logic, even though the event wasn’t Added, just Updated. I’m going to test the provided code in a Logic app to see if I get different results.

    Like

  6. Unfortunately, it doesn’t look like this works in Power Automate anymore. The Action Type expression returns as “Added” whether you are Adding or Updating an event. Checking the output shows Added events with “ActionType:Added,IsAdded:True,IsUpdated:False” and Updated events with “ActionType:Added,IsAdded:True,IsUpdated:True”. I’m looking to see if there is a way to further differentiate these two based on the IsUpdated expression difference.

    Like

Leave a comment