Skip to main content

Outlook Calendar

Set up and use the Microsoft 365 / Outlook Calendar integration for bidirectional sync between Event Schedule and Outlook through Microsoft Graph.

Prerequisites

Outlook Calendar sync is a free-tier feature, so no plan gate applies. Once these server credentials are in place, every schedule on the install can use it.

  1. A Microsoft Entra ID (Azure AD) tenant or an Azure account to register an application
  2. Access to the Azure Portal to create an app registration
  3. A redirect URI that matches your app registration exactly, so users can complete the OAuth sign-in
  4. For near-real-time inbound sync, a publicly reachable HTTPS URL, because Microsoft Graph must be able to call the webhook endpoint on your server
No public URL?

Installs without a public HTTPS URL still work. Instead of near-real-time webhooks, inbound changes are picked up by the 15-minute microsoft:sync polling fallback.

Queue worker required for webhooks

For near-real-time webhooks, run an asynchronous queue (set QUEUE_CONNECTION=database and keep php artisan queue:work running). Inbound sync is dispatched to the queue so Microsoft Graph gets a fast response. On the default sync connection the sync runs inside the webhook request, which can be slow enough that Graph deprovisions the subscription. Without a worker, inbound changes still arrive via the 15-minute poll.

Setup Instructions

1. Azure App Registration

  1. Go to the Azure Portal and open Microsoft Entra IDApp registrationsNew registration
  2. Enter a name for the application (for example, "Event Schedule")
  3. Under Supported account types, choose "Accounts in any organizational directory and personal Microsoft accounts" (this matches MICROSOFT_TENANT=common)
  4. Under Redirect URI, select the Web platform and enter: {APP_URL}/microsoft-calendar/callback
  5. Click Register

2. API Permissions

Event Schedule requests delegated permissions only, so it acts as the signed-in user and never gains tenant-wide calendar access.

  1. In the app registration, open API permissionsAdd a permissionMicrosoft GraphDelegated permissions
  2. Add the following delegated permissions:
    • Calendars.ReadWrite
    • offline_access
    • openid
    • email
    • profile
  3. If your tenant requires it, grant admin consent for the permissions
The scope list is fixed

Event Schedule always requests exactly these five scopes. offline_access is the one that yields a refresh token, so without it users are pushed back through sign-in as soon as the access token expires.

3. Client Secret and Client ID

  1. Open Certificates & secretsNew client secret, then copy the secret Value immediately (it is only shown once)
  2. Open the Overview page and copy the Application (client) ID

4. Environment Configuration

Add the following environment variables to your .env file:

.env
MICROSOFT_CLIENT_ID=your_application_client_id
MICROSOFT_CLIENT_SECRET=your_client_secret_value
MICROSOFT_REDIRECT_URI=https://your-domain.com/microsoft-calendar/callback
MICROSOFT_TENANT=common
MICROSOFT_WEBHOOK_SECRET=a_long_random_string

If you cache your configuration, run php artisan config:cache after editing .env, or the new values will not be picked up.

Variable Reference

Variable Required Description
MICROSOFT_CLIENT_ID Yes The Application (client) ID from the app registration Overview page. Until it is set, the Settings page shows "Outlook Calendar is not configured on this server" instead of a connect button
MICROSOFT_CLIENT_SECRET Yes The client secret Value created under Certificates & secrets
MICROSOFT_REDIRECT_URI Yes Must exactly match the redirect URI registered in Azure ({APP_URL}/microsoft-calendar/callback). It is sent on both the authorization request and the token exchange
MICROSOFT_TENANT No, defaults to common Use common for multi-tenant plus personal accounts, or your specific tenant id for a single-tenant app
MICROSOFT_WEBHOOK_SECRET Only for webhooks Any long random string. It is the clientState that authenticates inbound Microsoft Graph notifications. Not needed for polling-only installs
Webhook secret is the only authenticity check

Graph subscriptions cannot be created at all while MICROSOFT_WEBHOOK_SECRET is empty: the attempt is refused rather than made without a clientState. Once set, Graph echoes the value back on every change notification, and Event Schedule ignores any notification whose value does not match, answering 401 when none of them do.

Features

How Sync Works

  1. Connect an account: A user connects their Microsoft account through OAuth from SettingsOutlook Calendar. The tokens are stored on that user record
  2. Per-schedule calendar and direction: The schedule owner picks one Outlook calendar and a sync direction on IntegrationsOutlook Calendar of the schedule edit page
  3. Outbound: Publishing, editing or deleting an event pushes the change to the selected calendar
  4. Inbound: Microsoft Graph subscriptions notify the webhook endpoint, and a queued job pulls the changes in with a Graph delta query
  5. Polling fallback: A 15-minute microsoft:sync command catches anything webhooks miss, and is the only inbound path on installs without a public URL
  6. Subscription renewal: A daily microsoft:refresh-webhooks command renews Graph subscriptions, which are created with a 60-hour (about 2.5 day) expiry
Outlook sync uses the schedule owner's account only

The calendar selection, the Graph subscription, and every outbound and inbound sync run on the schedule owner's connected Microsoft account. Team members do not get their own Outlook sync, and the Outlook Calendar tab only shows the settings to the owner. Per-member calendar sync is a Google Calendar feature.

Sync Direction

Each schedule picks one of four options. Saving the tab creates or removes the Graph subscription for you.

Option What it does Graph subscription
To Outlook Calendar Published Event Schedule events are pushed to the selected Outlook calendar Not created
From Outlook Calendar Outlook events are imported into Event Schedule Created
Bidirectional Sync Both of the above: changes flow in each direction Created
No Sync Outlook sync is turned off for this schedule. Events already in Outlook are left alone Removed

Event Information Synced

  • Event name, as the Outlook subject
  • Description. By default the event description is sent as plain text; if the schedule has a Calendar Description Template set on IntegrationsAdvanced, the rendered template is sent instead (see the available variables). On an update with no template and an empty description, no body is sent at all, so notes you typed on the Outlook copy survive
  • Start time in the schedule's timezone, and an end time calculated from the event duration
  • Location, taken from the venue's best available address
  • Privacy: an unlisted event is marked Private in Outlook, everything else is normal
  • A Microsoft Teams join link, for online events when the toggle is enabled
What is not sent

Draft events are never pushed, so an event first appears in Outlook when you publish it. Events with no duration are sent as all-day Outlook events. Recurring events are sent as a single Outlook entry on the series start date rather than as an Outlook recurrence. Use the schedule's iCal feed when you need every date of a series in a calendar app.

Microsoft Teams Meeting Links

Enable Create Teams meetings for online events on the schedule's Outlook Calendar tab. Every event with no venue is then created in Outlook as a Teams for Business meeting, and the join link is written into the event's online event URL, but only when that field is still empty so a link you entered yourself is never overwritten.

Personal Microsoft accounts usually cannot create Teams for Business meetings. When Graph rejects the request, Event Schedule retries immediately without the Teams flags, so you get a normal Outlook event and no join link rather than a failed sync.

Importing From Outlook

Inbound sync uses a Graph delta query over a window running from 30 days ago to 365 days ahead. On the first run, or after you switch calendars, the whole window is read; after that only changes are fetched. Imported events:

  • Arrive already approved and use the schedule's slug pattern and default category
  • Convert the Outlook location into a venue, reusing one of your existing venues when the name or address matches
  • Are matched to an existing event by name and start time when there is no stored mapping yet, so an event you pushed out does not come back as a duplicate
Appointment bookings are protected

An event created by an appointment booking is owned by Event Schedule. Inbound sync never rewrites its name, description or time, so moving the Outlook copy will not move a customer's booking.

When an Event Is Deleted in Outlook

Schedules that import from Outlook also choose When an event is deleted in the connected calendar. The setting is shared with the Google Calendar integration, so if you have also connected Google Calendar the control appears on the Google tab instead of the Outlook tab and one choice covers both.

Option Result in Event Schedule
Keep it here The event stays exactly as it is. This is the default
Mark as cancelled The event is hidden but recoverable. Recommended when tickets are sold
Delete it here The event is removed. Events with ticket sales or an active ad boost are hidden instead of deleted

Only a real deletion in Outlook triggers this policy. An event that merely moves outside the sync window is left untouched.

An event that belongs to more than one schedule is only detached from this schedule, never cancelled or deleted outright, unless this schedule is the one that owns it. The other schedules keep the event as it is.

Real-Time Sync and Polling Fallback

  • Graph sends one notification per changed event; Event Schedule collapses them to at most one inbound sync per schedule per minute, then reads every pending change in a single delta request
  • The 15-minute microsoft:sync command polls the schedules whose direction is From Outlook Calendar or Bidirectional Sync, and is the primary path when no public URL is available
  • The daily microsoft:refresh-webhooks command extends any subscription due to expire within the next day, and recreates any that Graph has already dropped

Usage

Step by Step

1. Connect Outlook Calendar

  1. Go to your settings page (/settings)
  2. Find the "Outlook Calendar" section
  3. Click "Connect Outlook Calendar"
  4. Authorize the application in the Microsoft sign-in flow

If the section shows "Outlook Calendar is not configured on this server" instead of the button, MICROSOFT_CLIENT_ID is still missing from .env.

2. Choose a Calendar and Sync Direction

  1. Open Admin PanelScheduleEdit
  2. Go to the Integrations section and select the Outlook Calendar tab
  3. Pick which Outlook calendar to sync with
  4. Choose the sync direction: To Outlook Calendar, From Outlook Calendar, Bidirectional Sync, or No Sync
  5. Save the schedule. Only the schedule owner sees these controls

3. Push Events to Outlook

  1. Create and publish events as usual
  2. Every event published or edited from then on is pushed to the selected calendar automatically
  3. Events that already existed when you turned sync on are not pushed in bulk, so add them with the per-event button below

4. Sync a Single Event by Hand

  1. Open the event edit page
  2. Go to the Outlook Calendar section
  3. Click "Sync to Outlook Calendar", or "Remove from Outlook Calendar" to delete the Outlook copy
  4. The section only appears once a calendar is selected and the direction includes To Outlook Calendar

5. Enable Teams Meeting Links

  1. On the schedule's Outlook Calendar tab, turn on Create Teams meetings for online events
  2. Events with no venue are created as Teams meetings, and the join link is saved to the event when it has no link yet

Automatic Sync

Once the schedule owner has connected Outlook and the direction includes To Outlook Calendar, events are synced automatically when they are:

  • Published, whether that is a new event or a draft you just published
  • Edited, which updates the existing Outlook entry in place
  • Deleted, cancelled or un-published, which removes the Outlook copy
Scheduled Sync

Inbound polling and subscription renewal run through scheduled commands (microsoft:sync every 15 minutes and microsoft:refresh-webhooks daily). Make sure the Laravel scheduler cron is active: * * * * * php artisan schedule:run

API Endpoints

Endpoint Access Description
GET /microsoft-calendar/redirect Signed in Start OAuth flow
GET /microsoft-calendar/callback Signed in OAuth callback
GET /microsoft-calendar/reauthorize Signed in Re-run consent to obtain a refresh token
GET /microsoft-calendar/disconnect Signed in Disconnect Outlook Calendar. Deletes the Graph subscriptions on the schedules you own, clears their sync direction and calendar selection, drops the stored event mappings, and clears the tokens
GET /microsoft-calendar/calendars Signed in Get the user's calendars as JSON. This is what fills the calendar dropdown
POST /microsoft-calendar/sync/{subdomain} Schedule member Sync a whole schedule. Send sync_direction as to, from or both, or omit it to use the schedule's saved direction. There is no button for this, so it is the way to backfill events that predate the connection
POST /microsoft-calendar/sync-event/{subdomain}/{eventId} Schedule member Sync a specific event. This is what the per-event Sync to Outlook Calendar button calls
DELETE /microsoft-calendar/unsync-event/{subdomain}/{eventId} Schedule member Remove the event's Outlook copy and drop its mapping
GET /microsoft-calendar/webhook Public, throttled Microsoft Graph subscription validation handshake
POST /microsoft-calendar/webhook Public, throttled, clientState Microsoft Graph change notifications (also handles the validation handshake)

Scheduled Commands

These Artisan commands keep inbound sync and Graph subscriptions healthy:

Command Frequency Description
microsoft:sync Every 15 minutes Polls Outlook for changes (inbound sync fallback). Add --role= with a schedule id to poll just one schedule
microsoft:refresh-webhooks Daily Renews any Microsoft Graph subscription due to expire within the next day. Add --role= with a schedule id or subdomain to target one schedule, or --force to renew them all

These commands run through the Laravel scheduler, which requires the following cron entry:

crontab
* * * * * php artisan schedule:run

Troubleshooting

Common Issues

No refresh token / repeated re-authentication

  • Make sure the offline_access scope is granted in the app registration
  • Visit /microsoft-calendar/reauthorize to force a fresh consent prompt, which is what returns a new refresh token
  • If that fails, disconnect and reconnect the account

Events are not appearing in Outlook

  • The direction must be To Outlook Calendar or Bidirectional Sync, and a calendar must be selected
  • Only the schedule owner's connected account is used, so check who owns the schedule
  • Draft events are not pushed. Publish the event, or use the per-event Sync to Outlook Calendar button
  • Events created before sync was enabled are not pushed in bulk

Teams meeting link not created

  • Personal Microsoft accounts may not support Teams for Business meetings
  • In that case the app falls back to creating a normal event without a Teams link
  • The event must have no venue, and its online event URL must be empty for the join link to be saved

Inbound changes not updating

  • The direction must be From Outlook Calendar or Bidirectional Sync, otherwise no subscription exists
  • Confirm the app has a public HTTPS URL so Graph can reach the webhook endpoint
  • Inbound sync is queued, so a stopped queue worker looks exactly like a broken webhook
  • Without a public URL, rely on the 15-minute microsoft:sync poll and confirm the scheduler cron is running
  • Outlook changes outside the window of 30 days back to 365 days ahead are not imported

An event deleted in Outlook is still here

  • This is the default. When an event is deleted in the connected calendar starts on Keep it here
  • The control only appears once the direction is From Outlook Calendar or Bidirectional Sync, and it sits on the Google Calendar tab if that account is also connected
  • Events with ticket sales or an active ad boost are hidden rather than deleted, even when the policy is Delete it here

Subscription creation fails

  • Ensure MICROSOFT_WEBHOOK_SECRET is set, as subscription creation is refused without it
  • Ensure the notification URL is publicly reachable over HTTPS, because Graph validates it before the subscription is created

A full re-scan after switching calendars

  • The stored delta token belongs to one calendar, so changing the calendar clears it and the next run reads the whole window again
  • An expired or rejected delta token does the same thing once, automatically
  • Events already mapped, or matching an existing event by name and start time, are updated rather than duplicated

Logs

Sync operations are logged in the application logs. Check storage/logs/laravel.log for detailed information about sync operations.

Security Considerations

  1. Token Storage: Microsoft access and refresh tokens are encrypted at rest per user, and are never included in API responses
  2. Rotating Refresh Tokens: Microsoft rotates the refresh token on each refresh, and the app stores the latest one under a per-user lock so two workers cannot rotate at once
  3. OAuth State Check: The sign-in flow carries a random state value that must match the one held in the session, so a callback that was not started by the user is rejected
  4. Webhook Authentication: MICROSOFT_WEBHOOK_SECRET (the clientState) authenticates inbound Graph notifications, and mismatched notifications are rejected. The endpoint is public by necessity and is rate limited
  5. Delegated Access Only: The app requests delegated Calendars.ReadWrite, so it can only reach the calendars of the user who signed in, not the whole tenant
  6. Secrets in .env: Keep the client secret and webhook secret in .env, and never commit them to source control