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.
- 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
- 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
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.
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
- Go to the Azure Portal and open Microsoft Entra ID → App registrations → New registration
- Enter a name for the application (for example, "Event Schedule")
- Under Supported account types, choose "Accounts in any organizational directory and personal Microsoft accounts" (this matches
MICROSOFT_TENANT=common) - Under Redirect URI, select the Web platform and enter:
{APP_URL}/microsoft-calendar/callback - 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.
- In the app registration, open API permissions → Add a permission → Microsoft Graph → Delegated permissions
- Add the following delegated permissions:
Calendars.ReadWriteoffline_accessopenidemailprofile
- If your tenant requires it, grant admin consent for the permissions
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
- Open Certificates & secrets → New client secret, then copy the secret Value immediately (it is only shown once)
- Open the Overview page and copy the Application (client) ID
4. Environment Configuration
Add the following environment variables to your .env file:
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 |
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
- Connect an account: A user connects their Microsoft account through OAuth from Settings → Outlook Calendar. The tokens are stored on that user record
- Per-schedule calendar and direction: The schedule owner picks one Outlook calendar and a sync direction on Integrations → Outlook Calendar of the schedule edit page
- 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:synccommand catches anything webhooks miss, and is the only inbound path on installs without a public URL - Subscription renewal: A daily
microsoft:refresh-webhookscommand renews Graph subscriptions, which are created with a 60-hour (about 2.5 day) expiry
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 Integrations → Advanced, 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
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
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:synccommand 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-webhookscommand 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
- Go to your settings page (
/settings) - Find the "Outlook Calendar" section
- Click "Connect Outlook Calendar"
- 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
- Open Admin Panel → Schedule → Edit
- Go to the Integrations section and select the Outlook Calendar tab
- Pick which Outlook calendar to sync with
- Choose the sync direction: To Outlook Calendar, From Outlook Calendar, Bidirectional Sync, or No Sync
- Save the schedule. 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 with the per-event button below
4. Sync a Single Event by Hand
- Open the event edit page
- Go to the Outlook Calendar section
- Click "Sync to Outlook Calendar", or "Remove from Outlook Calendar" to delete the Outlook copy
- The section only appears once a calendar is selected and the direction includes To Outlook Calendar
5. Enable Teams Meeting Links
- On the schedule's Outlook Calendar tab, turn on Create Teams meetings for online events
- 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
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:
* * * * * php artisan schedule:run
Troubleshooting
Common Issues
No refresh token / repeated re-authentication
- Make sure the
offline_accessscope is granted in the app registration - Visit
/microsoft-calendar/reauthorizeto 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:syncpoll 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_SECRETis 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
- 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(theclientState) 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 - Secrets in .env: Keep the client secret and webhook secret in
.env, and never commit them to source control