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.

Feature overview: Outlook calendar sync

Overview

This page is for whoever runs the install. Outlook Calendar sync needs one app registration in Microsoft Entra ID and five environment variables, two of them optional. Once they are set, a schedule's owner can connect their Microsoft account and keep the schedule and one Outlook calendar in step, in one direction or both, through Microsoft Graph. Nothing is configured per schedule on the server.

Where It Lives in the App

Screen What is done there, and by whom
Settings → Integrations → Outlook Calendar A user connects their own Microsoft account here with Connect Outlook Calendar, and later disconnects it. Only a schedule owner's connection is ever used for sync
Edit Schedule → Integrations → Outlook Calendar row The schedule's owner picks the calendar, the sync direction and what a deletion in Outlook does here, and can turn on Create Teams meetings for online events
Event form → Listing → Calendar sync Shows whether this event has a copy in Outlook. Sync Now and Remove are the owner's to use
Included on every plan

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

Prerequisites

  • A Microsoft Entra ID (Azure AD) tenant or an Azure account to register an application
  • Access to the Azure Portal to create an app registration
  • A redirect URI that matches your app registration exactly, so users can complete the OAuth sign-in
  • The Laravel scheduler cron from Installation, which drives the inbound poll, the subscription renewals and the queue
  • 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
  • For near-real-time inbound sync, QUEUE_CONNECTION=database

The queue matters because inbound sync is dispatched to it, so that Microsoft Graph gets a fast response. On database, the scheduler's minutely queue worker runs the sync within about a minute; keep php artisan queue:work running as well only if you want it sooner. On the shipped sync connection the sync runs inside the webhook request, which can be slow enough that Graph deprovisions the subscription. Either way, the 15-minute poll catches anything a notification missed.

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.

Setup Instructions

1. Azure App Registration

  1. Go to the Azure Portal and open Microsoft Entra ID → App registrations → New 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 permissions → Add a permission → Microsoft Graph → Delegated permissions
  2. Add these five delegated permissions: Calendars.ReadWrite, offline_access, openid, email and 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. 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 & secrets → New 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.example carries the same five, commented out, in its "Outlook / Microsoft 365 calendar sync (optional)" block:

.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

The webhook secret is the only authenticity check on a notification. 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

Part What happens
Connect an account A user connects their Microsoft account through OAuth under Settings → Integrations → Outlook Calendar. The tokens are stored on that user record
Pick a calendar and a direction The schedule's owner picks one Outlook calendar and a sync direction in the Outlook Calendar row of Edit Schedule → Integrations
Outbound Publishing, editing or deleting an event pushes the change to the selected calendar
Inbound Microsoft Graph subscriptions notify the webhook endpoint, and a queued job pulls the changes in with a Graph delta query
Polling fallback A 15-minute microsoft:sync command catches anything webhooks miss, and is the only inbound path on installs without a public URL
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 row only shows the settings to the owner: anyone else, and an owner who has not connected yet, sees a Connect Outlook Calendar button there instead. Per-member calendar sync is a Google Calendar feature.

Sync Direction

Each schedule picks one of four options under Sync Direction. Saving the schedule 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: new events and edits flow in each direction. Deleting an event here removes its Outlook copy; deleting one in Outlook follows the deletion setting 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 under Integrations → Calendar text and feeds, 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: two hours when no duration is set, and an all-day entry when the duration is 0, which is how a one-day all-day Outlook entry is stored when it arrives here
  • 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

  • Drafts. A Draft or Internal event is never pushed by a save, so an event first appears in Outlook when you publish it
  • Recurrence. 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 in the schedule's Outlook Calendar row. 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 Event URL field, 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.

Events That Arrive 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. Events brought in this way:

  • Arrive already approved and use the schedule's slug pattern and default category
  • Take their name, description, start time and duration from the Outlook entry. The description is converted from HTML to Markdown
  • Convert the Outlook location into a venue, reusing one of your existing venues when the name or address matches. An event that already has a venue keeps it
  • 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
  • Never overwrite an appointment booking. An event created by a booking is owned by Event Schedule: inbound sync does not rewrite 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, under the sync direction. It is one setting for the schedule, shared with the Google Calendar integration, and it is shown in one place only: if you have also connected Google Calendar, it appears in the Google Calendar row instead of this one, and only while that row's own direction is From Google Calendar or Bidirectional Sync.

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 marked cancelled rather than removed, which is reversible. Recommended when tickets are sold
Delete it here The event is removed, which cannot be undone. Events with ticket sales or ad boost spend are marked cancelled instead of deleted

Only a real deletion in Outlook triggers this policy. An event that merely moves outside the sync window is left untouched. Marking an event cancelled this way does not notify anyone: the cancellation notice that Cancel event in the event editor can send to ticket buyers and to the event's interest list only goes out from there.

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.

Calendar Sync on an Event

Once a schedule has a calendar selected and a direction that includes To Outlook Calendar, the Listing tab of a saved event ends with Calendar sync, which has an Outlook Calendar line:

Status What it means, and what the line offers
Synced The event has a copy in the owner's Outlook calendar. Remove deletes the Outlook copy, after a confirmation
Not synced No copy yet. The line under it says why: "It is copied the next time you save." for a published event, and "Only an event guests can see is copied to the calendar." for a Draft or Internal one. Sync Now creates the copy at once
Not shown The event has not been saved yet, the schedule has no calendar selected, or its direction does not include To Outlook Calendar

The status is the schedule owner's copy, while Sync Now and Remove act with the Microsoft account of whoever is signed in, so they are the owner's buttons to use. If a request fails, the reason is shown in the row.

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

Steps 1 and 2 turn sync on, and are done by the schedule's owner.

  1. Connect Outlook Calendar

    1. Open Settings in the sidebar and choose the Integrations tab
    2. In Outlook Calendar, click Connect Outlook Calendar
    3. Authorize the application in the Microsoft sign-in flow. You come back to the same section, which now reads Outlook Calendar Connected

    If the section shows "Outlook Calendar is not configured on this server" instead of the button, MICROSOFT_CLIENT_ID is still missing from .env. Connecting is half of it: nothing syncs until a schedule is told to.

  2. Choose a Calendar and Sync Direction

    1. Open the schedule, click Edit Schedule, choose the Integrations tab and open the Outlook Calendar row
    2. Pick which Outlook calendar to sync with under Select Outlook Calendar
    3. Choose the Sync Direction: To Outlook Calendar, From Outlook Calendar, Bidirectional Sync, or No Sync
    4. Click Save

    Only the schedule owner sees these controls.

  3. Push Events to Outlook

    Create and publish events as usual. Every event published or edited from then on is pushed to the selected calendar automatically. Events that already existed when you turned sync on are not pushed in bulk, so add them one at a time with the next step.

  4. Sync a Single Event by Hand

    Open the event's form and choose the Listing tab; Calendar sync is at its end. On the Outlook Calendar line, click Sync Now, or Remove to delete the Outlook copy. It only appears once a calendar is selected and the direction includes To Outlook Calendar.

  5. Enable Teams Meeting Links

    In the schedule's Outlook Calendar row, turn on Create Teams meetings for online events and save. 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. Restoring a cancelled event puts it back

Inbound polling and subscription renewal run through the scheduled commands below, so the Laravel scheduler cron has to be active.

API Endpoints

These are the application's own session-authenticated routes, not part of the public REST API. "Signed in" means a signed-in user with a verified email address.

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
POST /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} Signed in Sync a whole schedule with the caller's own Microsoft connection, so it is the schedule owner's to call. Send sync_direction as to, from or both, which also saves it as the schedule's direction, or omit it to use the saved one. 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} Signed in Sync a specific event, with the caller's own Microsoft connection. This is what Sync Now calls
DELETE /microsoft-calendar/unsync-event/{subdomain}/{eventId} Signed in Remove the event's Outlook copy and drop its mapping. This is what Remove calls
GET /microsoft-calendar/webhook Public, rate limited to 10 requests per minute Microsoft Graph subscription validation handshake
POST /microsoft-calendar/webhook Public, rate limited to 60 requests per minute, 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

Pick the line that matches what you see.

"Outlook Calendar is not configured on this server"
  • MICROSOFT_CLIENT_ID is not set, so Settings shows this notice in place of the connect button
  • If the variable is in .env and the notice stays, the configuration is cached: run php artisan config:cache again
No refresh token, or 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
Sync stopped, and the schedule is back on No Sync
  • Microsoft ended the grant: access was removed in the Microsoft account, or a password change or sign-in policy invalidated it. When Microsoft reports that on the next token refresh, Event Schedule forgets the tokens and switches every schedule that user owns to No Sync, clearing its subscription and sync cursor
  • The calendar choice and the record of which event became which Outlook entry are kept, so reconnecting does not copy everything again
  • Connect again under Settings → Integrations → Outlook Calendar, then set the sync direction on each schedule and save
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 and Internal events are not pushed. Publish the event
  • Events created before sync was enabled are not pushed in bulk. Open each one and use Sync Now under Calendar sync on its Listing tab
Calendar sync is missing from an event's Listing tab
  • It only appears on a saved event, when the schedule has an Outlook calendar selected and its direction includes To Outlook Calendar
  • Select a calendar under Edit Schedule → Integrations → Outlook Calendar and save the schedule
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 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 if the cron entry stops, taking the queue worker with it, a working webhook looks exactly like a broken one
  • 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. If your Google Calendar is connected too, it sits in the Google Calendar row instead, and only while that row's direction is From Google Calendar or Bidirectional Sync
  • Events with ticket sales or ad boost spend are marked cancelled 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
  • The schedule still saves when the subscription cannot be created. The failure is only in the log, and inbound changes fall back to the 15-minute poll
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
  • Copies pushed before the switch stay on the calendar they were created on and keep being updated there. Only events pushed for the first time land on the new calendar

Logs

Sync operations are logged in the application logs. Check storage/logs/laravel.log for detailed information about sync operations, and storage/logs/scheduler.log for the scheduled sync and subscription-renewal runs.

Security Considerations

Safeguard What it does
Token storage Microsoft access and refresh tokens are encrypted at rest per user, and are never included in API responses
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
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
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
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
Clean disconnect Disconnecting deletes the Graph subscriptions of the schedules the user owns, switches them to No Sync, and deletes the stored tokens, event mappings and calendar selections. Nothing already in the Outlook calendar is deleted
Audit trail Connecting, disconnecting and syncing a whole schedule are written to the audit log
Secrets in .env Keep the client secret and webhook secret in .env, and never commit them to source control

Last updated 18 October 2026 Improve this page on GitHub