{"id":16113,"date":"2026-11-10T16:16:58","date_gmt":"2026-11-10T13:16:58","guid":{"rendered":"https:\/\/dhtmlx.com\/blog\/?p=16113"},"modified":"2026-11-10T18:10:53","modified_gmt":"2026-11-10T15:10:53","slug":"two-way-sync-between-dhtmlx-scheduler-and-google-calendar","status":"publish","type":"post","link":"https:\/\/dhtmlx.com\/blog\/two-way-sync-between-dhtmlx-scheduler-and-google-calendar\/","title":{"rendered":"Two-Way Sync Between DHTMLX Scheduler and Google Calendar"},"content":{"rendered":"<p>Connecting Google Calendar to a scheduling application involves more than displaying external events. Your app needs to obtain users&#8217; authorization to access their calendars, handle differences in event formats, and ensure that edits in your app reach Google Calendar. <\/p>\n<p>The DHTMLX <a href=\"https:\/\/github.com\/DHTMLX\/scheduler-google-calendar-demo\" target=\"_blank\">scheduler-google-calendar-demo<\/a> demonstrates how to integrate <a href=\"https:\/\/dhtmlx.com\/docs\/products\/dhtmlxScheduler\/\" target=\"_blank\">DHTMLX Scheduler<\/a> with a user\u2019s Google calendar using a Node.js and Express server that handles OAuth 2.0. The <a href=\"https:\/\/docs.dhtmlx.com\/scheduler\/integrations\/google-calendar\/google-calendar-sync\/\" target=\"_blank\">official integration guide<\/a> covers setup step by step. This article focuses on the architecture: how authentication works, how events are mapped, how edits reach Google, and how recurring events are handled. At the end, you\u2019ll find instructions on how to run the demo locally. <\/p>\n<h2>How Two-Way Sync Works in the Demo<\/h2>\n<p>Before adapting the integration to your own solution, it is important to understand how events move between Scheduler and Google Calendar. The demo syncs data in both directions, but each flow works differently: <\/p>\n<ul>\n<li><strong>From Google Calendar to Scheduler<\/strong>. When the page opens, or Scheduler requests a new date range, it sends a <strong>GET<\/strong> request to the <strong>\/events<\/strong> endpoint. The server then retrieves the selected calendars and their events from Google and converts the events to Scheduler\u2019s format.<\/li>\n<li><strong>From Scheduler to Google Calendar<\/strong>. When a user creates, edits, or deletes an event in Scheduler, the DataProcessor sends the change to the Express REST API. The server then calls the corresponding Google Calendar API method.<\/li>\n<\/ul>\n<p>The demo does not register webhooks to detect changes made in Google Calendar. These changes appear in Scheduler only after a page refresh or when the user navigates to a date range that Scheduler has not yet loaded. So the integration relies on explicit API requests rather than push updates from Google Calendar Scheduler. <\/p>\n<p>Now, we need to clarify how the application obtains permission to access calendars and protects its API routes.<\/p>\n<h2>Signing In with Passport and OAuth 2.0<\/h2>\n<p>The app uses OAuth 2.0 to request permission to access a user\u2019s Google Calendar. The demo uses OAuth for Google Calendar access, handling the authentication process with Passport and the passport-google-oauth20 strategy.<\/p>\n<p>The following route configuration in <a href=\"https:\/\/github.com\/DHTMLX\/scheduler-google-calendar-demo\/blob\/main\/server\/routes\/auth.route.ts\" target=\"_blank\">auth.route.ts<\/a> starts the  Google Calendar sign-in flow:<\/p>\n<div class=\"codecolorer-container javascript default\" style=\"overflow:auto;white-space:nowrap;\"><div class=\"javascript codecolorer\">router.<span class=\"kw1\">get<\/span><span class=\"br0\">&#40;<\/span><br \/>\n&nbsp; <span class=\"st0\">&quot;\/google&quot;<\/span><span class=\"sy0\">,<\/span><br \/>\n&nbsp; passport.<span class=\"me1\">authenticate<\/span><span class=\"br0\">&#40;<\/span><span class=\"st0\">&quot;google&quot;<\/span><span class=\"sy0\">,<\/span> <span class=\"br0\">&#123;<\/span><br \/>\n&nbsp; &nbsp; scope<span class=\"sy0\">:<\/span> <span class=\"br0\">&#91;<\/span><span class=\"st0\">&quot;profile&quot;<\/span><span class=\"sy0\">,<\/span> <span class=\"st0\">&quot;email&quot;<\/span><span class=\"sy0\">,<\/span> <span class=\"st0\">&quot;https:\/\/www.googleapis.com\/auth\/calendar&quot;<\/span><span class=\"br0\">&#93;<\/span><span class=\"sy0\">,<\/span><br \/>\n&nbsp; &nbsp; accessType<span class=\"sy0\">:<\/span> <span class=\"st0\">&quot;offline&quot;<\/span><span class=\"sy0\">,<\/span><br \/>\n&nbsp; &nbsp; prompt<span class=\"sy0\">:<\/span> <span class=\"st0\">&quot;consent&quot;<\/span><span class=\"sy0\">,<\/span><br \/>\n&nbsp; <span class=\"br0\">&#125;<\/span><span class=\"br0\">&#41;<\/span><br \/>\n<span class=\"br0\">&#41;<\/span><span class=\"sy0\">;<\/span><\/div><\/div>\n<p>The scope option defines which permissions the app asks the user for. The https:\/\/www.googleapis.com\/auth\/calendar scope gives the app full access to the user&#8217;s calendars, so it can list calendars and read, create, update, and delete events. The profile and email scopes give access to the user&#8217;s name and email address. <\/p>\n<p>Two authentication settings are also relevant. <strong>accessType: &#8220;offline&#8221;<\/strong> requests a refresh token alongside the short-lived access token. <strong>prompt: &#8220;consent&#8221;<\/strong> displays the consent screen even if the user has already authorized the app. Without <strong>prompt: &#8220;consent&#8221;<\/strong>, Google Calendar may issue a refresh token only on the first authorization, which can lead to a missing refresh token on subsequent sign-ins. <\/p>\n<p>When Google redirects back, Passport attaches both tokens to the user object, and <strong>serializeUser\/deserializeUser<\/strong> put that object into the session as is. Our demo does not use a database for user data or tokens. We intentionally keep this implementation simple, but a production system would normally store tokens securely in persistent storage and handle token rotation and revocation. <\/p>\n<p>The <strong>\/events<\/strong> endpoint routes are protected by a small middleware in <a href=\"https:\/\/github.com\/DHTMLX\/scheduler-google-calendar-demo\/blob\/main\/server\/index.ts\" target=\"_blank\">server\/index.ts<\/a>. Requests without an authenticated session receive a 401 response instead of reaching the route handlers.<\/p>\n<p>While your consent configuration has the Testing publishing status, sign-in works only for accounts you explicitly listed as testers. Moving to Production status requires Google&#8217;s verification process.<\/p>\n<p>Let&#8217;s see how the stored credentials are used to make actual Google Calendar API calls.<\/p>\n<h2>From Session Tokens to Requests<\/h2>\n<p>With tokens stored in the session, each outgoing request to Google carries them. The <a href=\"https:\/\/github.com\/DHTMLX\/scheduler-google-calendar-demo\/blob\/main\/server\/services\/googleService.ts\" target=\"_blank\">googleService.ts<\/a> source file contains the server-side implementation of the JavaScript Google Calendar API integration, using the googleapis library to make requests.<\/p>\n<p>For every operation, the <strong>oauthClent()<\/strong> function creates a new OAuth2 client using OAuth2 client out of the session tokens and passes this client into the appropriate Calendar API v3 method:<\/p>\n<div class=\"codecolorer-container javascript default\" style=\"overflow:auto;white-space:nowrap;\"><div class=\"javascript codecolorer\"><span class=\"kw1\">function<\/span> oauthClient<span class=\"br0\">&#40;<\/span>tokens<span class=\"sy0\">:<\/span> GoogleOAuthTokens<span class=\"br0\">&#41;<\/span> <span class=\"br0\">&#123;<\/span><br \/>\n&nbsp; <span class=\"kw1\">const<\/span> client <span class=\"sy0\">=<\/span> <span class=\"kw1\">new<\/span> google.<span class=\"me1\">auth<\/span>.<span class=\"me1\">OAuth2<\/span><span class=\"br0\">&#40;<\/span><br \/>\n&nbsp; &nbsp; config.<span class=\"me1\">GOOGLE_CLIENT_ID<\/span><span class=\"sy0\">,<\/span><br \/>\n&nbsp; &nbsp; config.<span class=\"me1\">GOOGLE_CLIENT_SECRET<\/span><span class=\"sy0\">,<\/span><br \/>\n&nbsp; &nbsp; config.<span class=\"me1\">GOOGLE_REDIRECT_URI<\/span><br \/>\n&nbsp; <span class=\"br0\">&#41;<\/span><span class=\"sy0\">;<\/span><br \/>\n&nbsp; client.<span class=\"me1\">setCredentials<\/span><span class=\"br0\">&#40;<\/span><span class=\"br0\">&#123;<\/span><br \/>\n&nbsp; &nbsp; access_token<span class=\"sy0\">:<\/span> tokens.<span class=\"me1\">accessToken<\/span><span class=\"sy0\">,<\/span><br \/>\n&nbsp; &nbsp; refresh_token<span class=\"sy0\">:<\/span> tokens.<span class=\"me1\">refreshToken<\/span><span class=\"sy0\">,<\/span><br \/>\n&nbsp; <span class=\"br0\">&#125;<\/span><span class=\"br0\">&#41;<\/span><span class=\"sy0\">;<\/span><br \/>\n&nbsp; <span class=\"kw1\">return<\/span> client<span class=\"sy0\">;<\/span><br \/>\n<span class=\"br0\">&#125;<\/span><br \/>\n&nbsp;<br \/>\n<span class=\"co1\">\/\/ ...<\/span><br \/>\n&nbsp;<br \/>\n<span class=\"kw5\">export<\/span> async <span class=\"kw1\">function<\/span> createEvent<span class=\"br0\">&#40;<\/span><br \/>\n&nbsp; tokens<span class=\"sy0\">:<\/span> GoogleOAuthTokens<span class=\"sy0\">,<\/span><br \/>\n&nbsp; calendarId<span class=\"sy0\">:<\/span> string <span class=\"sy0\">|<\/span> <span class=\"kw2\">undefined<\/span><span class=\"sy0\">,<\/span><br \/>\n&nbsp; gEvent<span class=\"sy0\">:<\/span> calendar_v3.<span class=\"me1\">Schema<\/span>$Event<br \/>\n<span class=\"br0\">&#41;<\/span><span class=\"sy0\">:<\/span> Promise<span class=\"sy0\">&lt;<\/span>calendar_v3.<span class=\"me1\">Schema<\/span>$Event<span class=\"sy0\">&gt;<\/span> <span class=\"br0\">&#123;<\/span><br \/>\n&nbsp; <span class=\"kw1\">const<\/span> <span class=\"br0\">&#123;<\/span> data <span class=\"br0\">&#125;<\/span> <span class=\"sy0\">=<\/span> await calendarClient.<span class=\"me1\">events<\/span>.<span class=\"me1\">insert<\/span><span class=\"br0\">&#40;<\/span><span class=\"br0\">&#123;<\/span><br \/>\n&nbsp; &nbsp; auth<span class=\"sy0\">:<\/span> oauthClient<span class=\"br0\">&#40;<\/span>tokens<span class=\"br0\">&#41;<\/span><span class=\"sy0\">,<\/span><br \/>\n&nbsp; &nbsp; calendarId<span class=\"sy0\">:<\/span> calendarId <span class=\"sy0\">||<\/span> <span class=\"st0\">&quot;primary&quot;<\/span><span class=\"sy0\">,<\/span><br \/>\n&nbsp; &nbsp; requestBody<span class=\"sy0\">:<\/span> gEvent<span class=\"sy0\">,<\/span><br \/>\n&nbsp; &nbsp; conferenceDataVersion<span class=\"sy0\">:<\/span> <span class=\"nu0\">1<\/span><span class=\"sy0\">,<\/span><br \/>\n&nbsp; <span class=\"br0\">&#125;<\/span><span class=\"br0\">&#41;<\/span><span class=\"sy0\">;<\/span><br \/>\n&nbsp; <span class=\"kw1\">return<\/span> data<span class=\"sy0\">;<\/span><br \/>\n<span class=\"br0\">&#125;<\/span><\/div><\/div>\n<p>Two things in this snippet are easy to miss. If the browser does not specify a calendar, the entry lands in the account&#8217;s primary one. The <strong>conferenceDataVersion: 1 option<\/strong> enables support for conference data, including Google Meet details. Without it, the API silently ignores any conference data in the request. <\/p>\n<p>Because the refresh token is passed to the client object, Google&#8217;s library can renew an expired access token on its own. A new client is created for each API call and the refreshed access token is not saved back to the session. The updated token is not retained by the app, therefore, a subsequent request may need to refresh the access token again.<\/p>\n<p>Now, we can proceed to the next step focused on how Scheduler communicates with the server. <\/p>\n<h2>The REST Contract<\/h2>\n<p>The server exposes four REST endpoints that match the request and response format Scheduler&#8217;s DataProcessor expects:<\/p>\n<ul>\n<li>GET \/events returns the calendars together with their entries. The payload contains a success flag, the data array, and a collections object holding the calendar list.<\/li>\n<li>POST \/events creates an entry and answers with the &#8220;inserted&#8221; action plus a tid value.<\/li>\n<li>PUT \/events\/:eventId saves changes and answers with &#8220;updated&#8221; plus tid.<\/li>\n<li>DELETE \/events\/:eventId removes an entry and answers with &#8220;deleted&#8221;.<\/li>\n<\/ul>\n<p>The <strong>tid<\/strong> value returned after creation may look like a minor detail, but subsequent updates and deletions depend on it. Why exactly is explained below, where we follow edits back to Google Calendar.<\/p>\n<h2>Loading Events from Google to Scheduler<\/h2>\n<p>In the browser, the DHTMLX JavaScript event scheduler opens in week mode, and dynamic loading is on:<\/p>\n<div class=\"codecolorer-container javascript default\" style=\"overflow:auto;white-space:nowrap;\"><div class=\"javascript codecolorer\">scheduler.<span class=\"me1\">init<\/span><span class=\"br0\">&#40;<\/span><span class=\"st0\">&quot;scheduler_here&quot;<\/span><span class=\"sy0\">,<\/span> <span class=\"kw1\">new<\/span> <span class=\"kw4\">Date<\/span><span class=\"br0\">&#40;<\/span><span class=\"br0\">&#41;<\/span><span class=\"sy0\">,<\/span> <span class=\"st0\">&quot;week&quot;<\/span><span class=\"br0\">&#41;<\/span><span class=\"sy0\">;<\/span><br \/>\nscheduler.<span class=\"me1\">setLoadMode<\/span><span class=\"br0\">&#40;<\/span><span class=\"st0\">&quot;week&quot;<\/span><span class=\"br0\">&#41;<\/span><span class=\"sy0\">;<\/span><\/div><\/div>\n<p>With the <strong>setLoadMode()<\/strong> method, the widget requests only the dates the user can currently see, adding from and to parameters to the request URL. The server converts these two values into Google&#8217;s <strong>timeMin<\/strong> and <strong>timeMax<\/strong> query parameters:<\/p>\n<div class=\"codecolorer-container javascript default\" style=\"overflow:auto;white-space:nowrap;\"><div class=\"javascript codecolorer\"><span class=\"kw1\">const<\/span> fromQuery <span class=\"sy0\">=<\/span> <span class=\"kw1\">typeof<\/span> req.<span class=\"me1\">query<\/span>.<span class=\"me1\">from<\/span> <span class=\"sy0\">===<\/span> <span class=\"st0\">&quot;string&quot;<\/span> <span class=\"sy0\">?<\/span> req.<span class=\"me1\">query<\/span>.<span class=\"me1\">from<\/span> <span class=\"sy0\">:<\/span> <span class=\"kw2\">undefined<\/span><span class=\"sy0\">;<\/span><br \/>\n<span class=\"kw1\">const<\/span> toQuery <span class=\"sy0\">=<\/span> <span class=\"kw1\">typeof<\/span> req.<span class=\"me1\">query<\/span>.<span class=\"me1\">to<\/span> <span class=\"sy0\">===<\/span> <span class=\"st0\">&quot;string&quot;<\/span> <span class=\"sy0\">?<\/span> req.<span class=\"me1\">query<\/span>.<span class=\"me1\">to<\/span> <span class=\"sy0\">:<\/span> <span class=\"kw2\">undefined<\/span><span class=\"sy0\">;<\/span><br \/>\n&nbsp;<br \/>\n<span class=\"kw1\">const<\/span> minDate <span class=\"sy0\">=<\/span> fromQuery <span class=\"sy0\">?<\/span> <span class=\"kw1\">new<\/span> <span class=\"kw4\">Date<\/span><span class=\"br0\">&#40;<\/span>fromQuery<span class=\"br0\">&#41;<\/span>.<span class=\"me1\">toISOString<\/span><span class=\"br0\">&#40;<\/span><span class=\"br0\">&#41;<\/span> <span class=\"sy0\">:<\/span> <span class=\"kw1\">new<\/span> <span class=\"kw4\">Date<\/span><span class=\"br0\">&#40;<\/span><span class=\"br0\">&#41;<\/span>.<span class=\"me1\">toISOString<\/span><span class=\"br0\">&#40;<\/span><span class=\"br0\">&#41;<\/span><span class=\"sy0\">;<\/span><br \/>\n<span class=\"kw1\">const<\/span> maxDate <span class=\"sy0\">=<\/span> toQuery <span class=\"sy0\">?<\/span> <span class=\"kw1\">new<\/span> <span class=\"kw4\">Date<\/span><span class=\"br0\">&#40;<\/span>toQuery<span class=\"br0\">&#41;<\/span>.<span class=\"me1\">toISOString<\/span><span class=\"br0\">&#40;<\/span><span class=\"br0\">&#41;<\/span> <span class=\"sy0\">:<\/span> <span class=\"kw2\">undefined<\/span><span class=\"sy0\">;<\/span><\/div><\/div>\n<p>First, the handler fetches the list of calendars, then requests events for all of them concurrently, and runs each entry through the mapper. The reply has two sections: an array of entries already converted to Scheduler&#8217;s format, and the calendars list placed under collections. Scheduler remembers such <a href=\"https:\/\/docs.dhtmlx.com\/scheduler\/guides\/loading-data\/\" target=\"_blank\">collections<\/a>, so later the client code can retrieve the list by calling the <strong>serverList()<\/strong> method with the &#8220;calendars&#8221; key, and the DataProcessor relies on precisely this.<\/p>\n<p>Event conversion is implemented in a separate eventMapper.ts module, which holds most of the integration&#8217;s complexity. Google Calendar and Scheduler describe the same event in noticeably different ways: <\/p>\n<p>TABLE!!!<\/p>\n<p>The <strong>toDhxEvent()<\/strong> function copies the event&#8217;s title and description, records which calendar the event belongs to, and then branches by event type. Timed events get their <strong>start_date<\/strong> and <strong>end_date<\/strong> directly from Google&#8217;s <strong>dateTime<\/strong> values. All-day events carry dates only, so the mapper converts them to midnight timestamps. <\/p>\n<p>Now you are familiar with how events reach Scheduler from Google Calendar. Next, we\u2019ll consider how edits made in Scheduler reach Google Calendar.<\/p>\n<h2>Sending Edits Back to Google<\/h2>\n<p>The reverse synchronization flow includes a few noteworthy aspects, such as preparing event data for Google Calendar\u2019s format, handling time zones correctly, and keeping event IDs consistent across both systems. So let us consider how these tasks are addressed in our demo project.<\/p>\n<h3>The DataProcessor as the Router<\/h3>\n<p>Scheduler sends every change a user makes (adding, editing, or removing an event) to a single callback function. The demo defines this callback in <a href=\"https:\/\/github.com\/DHTMLX\/scheduler-google-calendar-demo\/blob\/main\/client\/main.ts\" target=\"_blank\">main.ts<\/a>, where the client sets up Scheduler:<\/p>\n<div class=\"codecolorer-container javascript default\" style=\"overflow:auto;white-space:nowrap;\"><div class=\"javascript codecolorer\">scheduler.<span class=\"me1\">createDataProcessor<\/span><span class=\"br0\">&#40;<\/span>async <span class=\"br0\">&#40;<\/span><br \/>\n&nbsp; entity<span class=\"sy0\">:<\/span> <span class=\"st0\">&quot;event&quot;<\/span><span class=\"sy0\">,<\/span><br \/>\n&nbsp; action<span class=\"sy0\">:<\/span> SchedulerDataAction<span class=\"sy0\">,<\/span><br \/>\n&nbsp; data<span class=\"sy0\">:<\/span> DhxEvent<span class=\"sy0\">,<\/span><br \/>\n&nbsp; id<span class=\"sy0\">:<\/span> string <span class=\"sy0\">|<\/span> number<br \/>\n<span class=\"br0\">&#41;<\/span> <span class=\"sy0\">=&gt;<\/span> <span class=\"br0\">&#123;<\/span><br \/>\n&nbsp; <span class=\"kw1\">const<\/span> calendars <span class=\"sy0\">=<\/span> scheduler.<span class=\"me1\">serverList<\/span><span class=\"br0\">&#40;<\/span><span class=\"st0\">&quot;calendars&quot;<\/span><span class=\"br0\">&#41;<\/span> as MappedCalendar<span class=\"br0\">&#91;<\/span><span class=\"br0\">&#93;<\/span><span class=\"sy0\">;<\/span><br \/>\n&nbsp; data.<span class=\"me1\">calendarId<\/span> <span class=\"sy0\">=<\/span> calendars<span class=\"br0\">&#91;<\/span><span class=\"nu0\">0<\/span><span class=\"br0\">&#93;<\/span><span class=\"sy0\">?<\/span>.<span class=\"me1\">id<\/span><span class=\"sy0\">;<\/span><br \/>\n&nbsp; data.<span class=\"me1\">timeZone<\/span> <span class=\"sy0\">=<\/span> momentTz.<span class=\"me1\">tz<\/span>.<span class=\"me1\">guess<\/span><span class=\"br0\">&#40;<\/span><span class=\"br0\">&#41;<\/span><span class=\"sy0\">;<\/span><br \/>\n&nbsp; <span class=\"kw1\">return<\/span> fetchEvent<span class=\"br0\">&#40;<\/span>action<span class=\"sy0\">,<\/span> data<span class=\"sy0\">,<\/span> id<span class=\"br0\">&#41;<\/span><span class=\"sy0\">;<\/span><br \/>\n<span class=\"br0\">&#125;<\/span><span class=\"br0\">&#41;<\/span><span class=\"sy0\">;<\/span><\/div><\/div>\n<p>Passing a function to <strong>createDataProcessor<\/strong> instead of a URL turns that function into a custom router. The Scheduler calls it for each change and waits for the promise it returns. The router adds two values to the event data before sending the change to the server: <\/p>\n<ul>\n<li><strong>calendarId<\/strong> is simply the first calendar from the stored collection. The tutorial admits this is a shortcut for the demo. In a real product the user should decide where the entry goes.<\/li>\n<li><strong>timeZone<\/strong> is the browser\u2019s local time zone, detected by moment-timezone. The server uses it to format <strong>dateTime<\/strong> strings and to fill in the <strong>timeZone<\/strong> property on Google&#8217;s side.<\/li>\n<\/ul>\n<p>The <strong>fetchEvent()<\/strong> method then maps the action to an HTTP request using the appropriate method, URL, and payload:<\/p>\n<div class=\"codecolorer-container javascript default\" style=\"overflow:auto;white-space:nowrap;\"><div class=\"javascript codecolorer\">async <span class=\"kw1\">function<\/span> fetchEvent<span class=\"br0\">&#40;<\/span>action<span class=\"sy0\">,<\/span> data<span class=\"sy0\">,<\/span> id<span class=\"br0\">&#41;<\/span> <span class=\"br0\">&#123;<\/span><br \/>\n&nbsp; <span class=\"kw1\">const<\/span> requestConfigs <span class=\"sy0\">=<\/span> <span class=\"br0\">&#123;<\/span><br \/>\n&nbsp; &nbsp; create<span class=\"sy0\">:<\/span> <span class=\"br0\">&#91;<\/span><span class=\"st0\">&quot;POST&quot;<\/span><span class=\"sy0\">,<\/span> <span class=\"st0\">&quot;events\/&quot;<\/span><span class=\"sy0\">,<\/span> data<span class=\"br0\">&#93;<\/span><span class=\"sy0\">,<\/span><br \/>\n&nbsp; &nbsp; update<span class=\"sy0\">:<\/span> <span class=\"br0\">&#91;<\/span><span class=\"st0\">&quot;PUT&quot;<\/span><span class=\"sy0\">,<\/span> `events<span class=\"sy0\">\/<\/span>$<span class=\"br0\">&#123;<\/span>id<span class=\"br0\">&#125;<\/span>`<span class=\"sy0\">,<\/span> data<span class=\"br0\">&#93;<\/span><span class=\"sy0\">,<\/span><br \/>\n&nbsp; &nbsp; <span class=\"kw1\">delete<\/span><span class=\"sy0\">:<\/span> <span class=\"br0\">&#91;<\/span><span class=\"st0\">&quot;DELETE&quot;<\/span><span class=\"sy0\">,<\/span> `events<span class=\"sy0\">\/<\/span>$<span class=\"br0\">&#123;<\/span>id<span class=\"br0\">&#125;<\/span>`<span class=\"sy0\">,<\/span> data<span class=\"br0\">&#93;<\/span><span class=\"sy0\">,<\/span><br \/>\n<span class=\"br0\">&#125;<\/span><span class=\"sy0\">;<\/span><\/div><\/div>\n<p>For updates and deletions, the URL includes the event\u2019s ID. This parameter is used by the server to figure out whether a Google Calendar event should be modified or removed.<\/p>\n<h2>Preparing the Payload for Google<\/h2>\n<p>On the server, the <strong>toGoogleEventPayload()<\/strong> method converts the incoming Scheduler event into Google&#8217;s format. The key decision here is whether the event should include date-only fields or date-time fields:<\/p>\n<div class=\"codecolorer-container javascript default\" style=\"overflow:auto;white-space:nowrap;\"><div class=\"javascript codecolorer\"><span class=\"kw1\">const<\/span> isAllDay <span class=\"sy0\">=<\/span><br \/>\n&nbsp; dhx.<span class=\"me1\">duration<\/span> <span class=\"sy0\">===<\/span> <span class=\"nu0\">24<\/span> <span class=\"sy0\">*<\/span> <span class=\"nu0\">60<\/span> <span class=\"sy0\">*<\/span> <span class=\"nu0\">60<\/span> <span class=\"sy0\">||<\/span><br \/>\n&nbsp; <span class=\"kw1\">new<\/span> <span class=\"kw4\">Date<\/span><span class=\"br0\">&#40;<\/span>dhx.<span class=\"me1\">end_date<\/span><span class=\"br0\">&#41;<\/span>.<span class=\"me1\">getTime<\/span><span class=\"br0\">&#40;<\/span><span class=\"br0\">&#41;<\/span> <span class=\"sy0\">-<\/span> <span class=\"kw1\">new<\/span> <span class=\"kw4\">Date<\/span><span class=\"br0\">&#40;<\/span>dhx.<span class=\"me1\">start_date<\/span><span class=\"br0\">&#41;<\/span>.<span class=\"me1\">getTime<\/span><span class=\"br0\">&#40;<\/span><span class=\"br0\">&#41;<\/span> <span class=\"sy0\">===<\/span> <span class=\"nu0\">24<\/span> <span class=\"sy0\">*<\/span> <span class=\"nu0\">60<\/span> <span class=\"sy0\">*<\/span> <span class=\"nu0\">60<\/span> <span class=\"sy0\">*<\/span> <span class=\"nu0\">1000<\/span><span class=\"sy0\">;<\/span><\/div><\/div>\n<p>An all-day event is sent with date fields only. Timed events get <strong>dateTime<\/strong> strings formatted by moment-timezone in the user&#8217;s time zone, along with an explicit <strong>timeZone<\/strong>. <\/p>\n<p>Keep in mind that the condition checks whether <strong>dhx.duration<\/strong> equals 86,400 seconds or whether the difference between the start and end dates is exactly 24 hours. As a result, multi-day all-day events can be treated as timed events spanning several days.<\/p>\n<h2>Handling Time Zones Correctly<\/h2>\n<p>Date formatting introduces another challenging task: time zones. Passing a timeZone value does not settle the matter completely. Scheduler converts dates into text using its <strong>format_date<\/strong> template, and the result is a local time without any offset, for example &#8220;2026-10-16 17:00&#8221;. The server passes this string to new <strong>Date()<\/strong>, which interprets it as a time in the server&#8217;s time zone. Only then is the result converted to the user&#8217;s time zone. <\/p>\n<p>During local development, the browser and the server share the same time zone, and the issue may go unnoticed. <\/p>\n<p>But if the server runs in UTC, an event that a user in Minsk (UTC+3) creates for 17:00 appears in Google Calendar at 20:00. <\/p>\n<p>There are two possible fixes: make the client send dates with an offset (for instance, redefine <strong>scheduler.templates.format_date<\/strong> to return <strong>date.toISOString()<\/strong>), or let the server interpret incoming strings in the user&#8217;s time zone via <strong>moment.tz(value, timeZone)<\/strong>.<\/p>\n<h2>Closing the Loop with Event IDs<\/h2>\n<p>When an event is first created in Scheduler, it has a temporary ID generated on the client. Google assigns a permanent one when it stores the record, and the POST handler passes this ID back as <strong>tid<\/strong>:<\/p>\n<div class=\"codecolorer-container javascript default\" style=\"overflow:auto;white-space:nowrap;\"><div class=\"javascript codecolorer\">res.<span class=\"me1\">status<\/span><span class=\"br0\">&#40;<\/span><span class=\"nu0\">201<\/span><span class=\"br0\">&#41;<\/span>.<span class=\"me1\">json<\/span><span class=\"br0\">&#40;<\/span><span class=\"br0\">&#123;<\/span> action<span class=\"sy0\">:<\/span> <span class=\"st0\">&quot;inserted&quot;<\/span><span class=\"sy0\">,<\/span> tid<span class=\"sy0\">:<\/span> gEvent.<span class=\"me1\">id<\/span> <span class=\"br0\">&#125;<\/span><span class=\"br0\">&#41;<\/span><span class=\"sy0\">;<\/span><\/div><\/div>\n<p>Scheduler reads <strong>tid<\/strong> from the response and replaces the temporary event ID with Google\u2019s ID. Subsequent update and delete requests include this ID in the URL, so the server does not need an ID mapping table. This response format follows Scheduler\u2019s <a href=\"https:\/\/docs.dhtmlx.com\/scheduler\/guides\/server-integration\/\" target=\"_blank\">server-side integration contract<\/a>.<\/p>\n<p>To update events, the server uses <strong>events.patch<\/strong> instead of <strong>events.update<\/strong>. The <strong>patch()<\/strong> method modifies only the properties included in the request, leaving the other ones intact.<\/p>\n<h2>Recurring Events<\/h2>\n<p>A recurring event series is another important aspect that may cause problems during the integration. DHTMLX Scheduler and Google Calendar use different approaches to representing event series. In this article, we are focusing on handling series end dates, representing changes in individual occurrences, and the way deletions are synchronized between calendars.<\/p>\n<h3>Calculating the End of a Series<\/h3>\n<p>To display a recurring event series, Scheduler requires three items: the pattern, the length of one instance, and the date after which the series stops. The <strong>toDhxEvent()<\/strong> method takes the pattern from Google&#8217;s recurrence array, drops the <strong>&#8220;RRULE:&#8221;<\/strong> part, and calculates the duration of one instance from the first occurrence&#8217;s start and end. Finding the end date takes a separate helper, because Google stores it as the UNTIL parameter in the recurrence rule:<\/p>\n<div class=\"codecolorer-container javascript default\" style=\"overflow:auto;white-space:nowrap;\"><div class=\"javascript codecolorer\"><span class=\"co1\">\/\/ convert UNTIL=20260129T205959Z -&gt; '2026-01-29T20:59:59Z' if it exists<\/span><br \/>\n<span class=\"co1\">\/\/ if there is no UNTIL -&gt; event repeat infinitely -&gt; return '9999-02-01T00:00:00Z'<\/span><br \/>\n<span class=\"kw1\">function<\/span> calculateEndDate<span class=\"br0\">&#40;<\/span>gEvent<span class=\"sy0\">:<\/span> calendar_v3.<span class=\"me1\">Schema<\/span>$Event<span class=\"br0\">&#41;<\/span><span class=\"sy0\">:<\/span> <span class=\"kw4\">Date<\/span> <span class=\"br0\">&#123;<\/span><br \/>\n&nbsp; <span class=\"kw1\">const<\/span> until <span class=\"sy0\">=<\/span> <span class=\"kw4\">String<\/span><span class=\"br0\">&#40;<\/span>gEvent.<span class=\"me1\">recurrence<\/span><span class=\"sy0\">?<\/span>.<span class=\"br0\">&#91;<\/span><span class=\"nu0\">0<\/span><span class=\"br0\">&#93;<\/span> <span class=\"sy0\">??<\/span> <span class=\"st0\">&quot;&quot;<\/span><span class=\"br0\">&#41;<\/span>.<span class=\"me1\">match<\/span><span class=\"br0\">&#40;<\/span><span class=\"co2\">\/RRULE:.*?UNTIL=([^;]+)\/<\/span><span class=\"br0\">&#41;<\/span><span class=\"sy0\">?<\/span>.<span class=\"br0\">&#91;<\/span><span class=\"nu0\">1<\/span><span class=\"br0\">&#93;<\/span><span class=\"sy0\">;<\/span><br \/>\n<br \/>\n&nbsp; <span class=\"kw1\">return<\/span> until<br \/>\n&nbsp; &nbsp; <span class=\"sy0\">?<\/span> <span class=\"kw1\">new<\/span> <span class=\"kw4\">Date<\/span><span class=\"br0\">&#40;<\/span><br \/>\n&nbsp; &nbsp; &nbsp; &nbsp; until.<span class=\"me1\">replace<\/span><span class=\"br0\">&#40;<\/span><span class=\"co2\">\/^([0-9]{4})([0-9]{2})([0-9]{2})T([0-9]{2})([0-9]{2})([0-9]{2})Z$\/<\/span><span class=\"sy0\">,<\/span> <span class=\"st0\">&quot;$1-$2-$3T$4:$5:$6Z&quot;<\/span><span class=\"br0\">&#41;<\/span><br \/>\n&nbsp; &nbsp; &nbsp; <span class=\"br0\">&#41;<\/span><br \/>\n&nbsp; &nbsp; <span class=\"sy0\">:<\/span> <span class=\"kw1\">new<\/span> <span class=\"kw4\">Date<\/span><span class=\"br0\">&#40;<\/span><span class=\"nu0\">9999<\/span><span class=\"sy0\">,<\/span> <span class=\"nu0\">1<\/span><span class=\"sy0\">,<\/span> <span class=\"nu0\">1<\/span><span class=\"br0\">&#41;<\/span><span class=\"sy0\">;<\/span><br \/>\n<span class=\"br0\">&#125;<\/span><\/div><\/div>\n<p>The <strong>calculateEndDate()<\/strong> method takes the <strong>UNTIL<\/strong> value from the rule and converts it from the compact iCalendar format into an ISO string that Date can read. If the rule has no UNTIL, the method assumes the series never ends and returns a placeholder date far in the future: February 1, 9999. Note that <strong>new Date(9999, 1, 1)<\/strong> uses the server\u2019s local time zone, so the Z in the comment is only correct when the server runs in UTC. For a &#8220;never ends&#8221; placeholder, this doesn\u2019t matter in practice.<\/p>\n<p>In the other direction, <strong>toGoogleEventPayload()<\/strong> adds the <strong>&#8220;RRULE:&#8221;<\/strong> prefix back. For a timed series, it also sets the end of a single occurrence to <strong>start_date<\/strong> plus duration. This is needed because Scheduler uses <strong>end_date<\/strong> to define when the entire series ends, not the end of one occurrence.<\/p>\n<h3>Modifying Occurrences<\/h3>\n<p>When a user moves or edits a single occurrence in a recurring series, Google stores it as an exception linked to the series. The mapper converts <strong>recurringEventId<\/strong> to <strong>recurring_event_id<\/strong> and <strong>originalStartTime<\/strong> to <strong>original_start<\/strong>, allowing Scheduler to identify which part of the original series is replaced by this exception.<\/p>\n<p>Canceled occurrences have <strong>status: &#8220;cancelled&#8221;<\/strong>, which the mapper converts to <strong>deleted: true<\/strong> in Scheduler. The <strong>toGoogleEventPayload()<\/strong> function maps the corresponding fields back to Google\u2019s format and converts <strong>deleted: true<\/strong> to <strong>status: &#8220;cancelled&#8221;<\/strong>.<\/p>\n<p>For details on how Scheduler represents recurring event series and their modified occurrences, see the <a href=\"https:\/\/docs.dhtmlx.com\/scheduler\/guides\/recurring-events\/\" target=\"_blank\">Recurring Events<\/a> guide.<\/p>\n<h3>Deleting Recurring Events and Occurrences<\/h3>\n<p>Before forwarding a deletion to Google Calendar, the DELETE handler checks the event ID to decide whether the request is needed:<\/p>\n<div class=\"codecolorer-container javascript default\" style=\"overflow:auto;white-space:nowrap;\"><div class=\"javascript codecolorer\"><span class=\"co1\">\/\/ 1) When the main recurring event is deleted, Scheduler then deletes all exceptions.<\/span><br \/>\n<span class=\"co1\">\/\/ 2) If this is an exception of a recurring event, there is nothing to delete on Google side.<\/span><br \/>\n<span class=\"co1\">\/\/ Google Calendar removes occurrences when deleting the main recurring event.<\/span><br \/>\n<span class=\"kw1\">const<\/span> dhxId <span class=\"sy0\">=<\/span> req.<span class=\"me1\">body<\/span><span class=\"sy0\">?<\/span>.<span class=\"me1\">id<\/span> as string <span class=\"sy0\">|<\/span> <span class=\"kw2\">undefined<\/span><span class=\"sy0\">;<\/span><br \/>\n<span class=\"kw1\">if<\/span> <span class=\"br0\">&#40;<\/span><span class=\"kw1\">typeof<\/span> dhxId <span class=\"sy0\">===<\/span> <span class=\"st0\">&quot;string&quot;<\/span> <span class=\"sy0\">&amp;&amp;<\/span> dhxId.<span class=\"me1\">indexOf<\/span><span class=\"br0\">&#40;<\/span><span class=\"st0\">&quot;_&quot;<\/span><span class=\"br0\">&#41;<\/span> <span class=\"sy0\">&gt;<\/span> <span class=\"sy0\">-<\/span><span class=\"nu0\">1<\/span><span class=\"br0\">&#41;<\/span> <span class=\"br0\">&#123;<\/span><br \/>\n&nbsp; res.<span class=\"me1\">json<\/span><span class=\"br0\">&#40;<\/span><span class=\"br0\">&#123;<\/span> action<span class=\"sy0\">:<\/span> <span class=\"st0\">&quot;deleted&quot;<\/span> <span class=\"br0\">&#125;<\/span><span class=\"br0\">&#41;<\/span><span class=\"sy0\">;<\/span><br \/>\n&nbsp; <span class=\"kw1\">return<\/span><span class=\"sy0\">;<\/span><br \/>\n<span class=\"br0\">&#125;<\/span><\/div><\/div>\n<p>The condition checks whether the event ID contains an underscore. In our demo, an event ID with an underscore is treated as the ID of an individually modified occurrence. For such identifiers, the server confirms the deletion to Scheduler but sends nothing to Google Calendar. The code comment explains the reason: deleting the parent recurring event in Google Calendar also removes its related occurrences, so they don\u2019t need to be deleted one by one.<\/p>\n<p>Removing repeating entries produces these calls:<\/p>\n<ul>\n<li>Removing the whole series (the &#8220;All events&#8221; option) sends one <strong>DELETE<\/strong> request for the series.<\/li>\n<li>Removing an ordinary instance sends a <strong>POST<\/strong> to <strong>\/events<\/strong> with <strong>deleted: true<\/strong>, <strong>recurring_event_id<\/strong> and <strong>original_start<\/strong>.<\/li>\n<li>Removing an instance that had been edited earlier sends the same <strong>POST<\/strong> followed by a <strong>DELETE<\/strong> for that exception.<\/li>\n<\/ul>\n<p>Deleting a series works as mentioned in the comment, although Scheduler sends a single request for the parent event and no separate requests for its exceptions. In practice, the underscore check only fires when someone deletes a previously edited instance.<\/p>\n<p>The POST request described above goes through <strong>toGoogleEventPayload()<\/strong> and becomes an <strong>events.insert<\/strong> call with a <strong>&#8220;cancelled&#8221; status<\/strong>, <strong>recurringEventId<\/strong>, and <strong>originalStartTime<\/strong>. However, the Google Calendar API reference marks <strong>recurringEventId<\/strong> as read-only, so Google Calendar may ignore it. The API expects you to modify an occurrence through its own ID instead: get the instance with <strong>events.instances<\/strong>, then call <strong>events.patch<\/strong> or <strong>events.delete<\/strong> on it. Test this scenario on a real account, and if the occurrence isn&#8217;t removed, switch to this approach.<\/p>\n<h2>Local Demo Setup<\/h2>\n<p>To run the demo with your personal Google account, install a currently supported Node.js LTS release or later and make sure you can access the Google Cloud console. Then follow these steps:<\/p>\n<ol>\n<li>Enable the <strong>Google Calendar API<\/strong> for your Cloud project.<\/li>\n<li>Configure the OAuth consent screen, keep the publishing status set to <strong>Testing<\/strong>, and add each Google account you plan to use as a test user.<\/li>\n<li>Create an OAuth client ID of the Web application type and add http:\/\/localhost:3000\/auth\/google\/callback as an authorized redirect URI.<\/li>\n<li>Copy .env.example to .env and set the client ID, client secret, redirect URI, session secret, and port.<\/li>\n<li>Run npm install to install dependencies, start the app with npm run start, and open http:\/\/localhost:3000.<\/li>\n<\/ol>\n<p>Check two environment settings before you start:<\/p>\n<ul>\n<li>If <strong>NODE_ENV<\/strong> is set to production, npm skips development dependencies, including tsx. Run npm install &#8211;include=dev to install them.<\/li>\n<li>If your shell already defines <strong>PORT<\/strong>, that value takes precedence over .env. Make sure the server port matches the port in your redirect URI.<\/li>\n<\/ul>\n<p>The demo uses the @dhx\/trial-scheduler trial package. <\/p>\n<p>Clone the <a href=\"https:\/\/github.com\/DHTMLX\/scheduler-google-calendar-demo\" target=\"_blank\">scheduler-google-calendar-demo repository<\/a> to try the integration with your own Google Cloud project. For detailed setup instructions, see the official <a href=\"https:\/\/docs.dhtmlx.com\/scheduler\/integrations\/google-calendar\/google-calendar-sync\/\" target=\"_blank\">DHTMLX integration guide<\/a>.<\/p>\n<h2>Final Thoughts<\/h2>\n<p>The DHTMLX integration demo is a practical example of integrating a scheduling calendar with Google Calendar. In this blog post, we discussed the main steps involved in the process, from authorizing users and loading Google Calendar events into Scheduler to converting event data between their formats and forwarding changes made in Scheduler to Google Calendar. You can use this as a starting point for implementing a similar integration in your own web project.<\/p>\n<h2>Related Materials<\/h2>\n<ul>\n<li><a href=\"https:\/\/docs.dhtmlx.com\/scheduler\/\" target=\"_blank\">DHTMLX Scheduler documentation<\/a><\/li>\n<li><a href=\"https:\/\/github.com\/DHTMLX\/scheduler-google-calendar-demo\" target=\"_blank\">DHTMLX Scheduler with Google Calendar (GitHub demo)<\/a><\/li>\n<li><a href=\"https:\/\/docs.dhtmlx.com\/scheduler\/integrations\/google-calendar\/google-calendar-sync\/\" target=\"_blank\">DHTMLX Scheduler: Two-way sync with Google Calendar (Guide)<\/a><\/li>\n<li><a href=\"https:\/\/www.npmjs.com\/package\/dhtmlx-scheduler\" target=\"_blank\">DHTMLX Scheduler (npm package)<\/a><\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>Connecting Google Calendar to a scheduling application involves more than displaying external events. Your app needs to obtain users&#8217; authorization to access their calendars, handle differences in event formats, &#8230;<\/p>\n","protected":false},"author":101014,"featured_media":16114,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[71],"tags":[33,15],"class_list":["post-16113","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-tutorials","tag-all-posts","tag-dhtmlxscheduler"],"_links":{"self":[{"href":"https:\/\/dhtmlx.com\/blog\/wp-json\/wp\/v2\/posts\/16113","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/dhtmlx.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/dhtmlx.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/dhtmlx.com\/blog\/wp-json\/wp\/v2\/users\/101014"}],"replies":[{"embeddable":true,"href":"https:\/\/dhtmlx.com\/blog\/wp-json\/wp\/v2\/comments?post=16113"}],"version-history":[{"count":6,"href":"https:\/\/dhtmlx.com\/blog\/wp-json\/wp\/v2\/posts\/16113\/revisions"}],"predecessor-version":[{"id":16120,"href":"https:\/\/dhtmlx.com\/blog\/wp-json\/wp\/v2\/posts\/16113\/revisions\/16120"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/dhtmlx.com\/blog\/wp-json\/wp\/v2\/media\/16114"}],"wp:attachment":[{"href":"https:\/\/dhtmlx.com\/blog\/wp-json\/wp\/v2\/media?parent=16113"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/dhtmlx.com\/blog\/wp-json\/wp\/v2\/categories?post=16113"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/dhtmlx.com\/blog\/wp-json\/wp\/v2\/tags?post=16113"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}