Connecting Google Calendar to a scheduling application involves more than displaying external events. Your app needs to obtain users’ authorization to access their calendars, handle differences in event formats, and ensure that edits in your app reach Google Calendar.
The DHTMLX scheduler-google-calendar-demo demonstrates how to integrate DHTMLX Scheduler with a user’s Google calendar using a Node.js and Express server that handles OAuth 2.0. The official integration guide 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’ll find instructions on how to run the demo locally.
How Two-Way Sync Works in the Demo
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:
- From Google Calendar to Scheduler. When the page opens, or Scheduler requests a new date range, it sends a GET request to the /events endpoint. The server then retrieves the selected calendars and their events from Google and converts the events to Scheduler’s format.
- From Scheduler to Google Calendar. 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.
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.
Now, we need to clarify how the application obtains permission to access calendars and protects its API routes.
Signing In with Passport and OAuth 2.0
The app uses OAuth 2.0 to request permission to access a user’s Google Calendar. The demo uses OAuth for Google Calendar access, handling the authentication process with Passport and the passport-google-oauth20 strategy.
The following route configuration in auth.route.ts starts the Google Calendar sign-in flow:
"/google",
passport.authenticate("google", {
scope: ["profile", "email", "https://www.googleapis.com/auth/calendar"],
accessType: "offline",
prompt: "consent",
})
);
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’s calendars, so it can list calendars and read, create, update, and delete events. The profile and email scopes give access to the user’s name and email address.
Two authentication settings are also relevant. accessType: “offline” requests a refresh token alongside the short-lived access token. prompt: “consent” displays the consent screen even if the user has already authorized the app. Without prompt: “consent”, Google Calendar may issue a refresh token only on the first authorization, which can lead to a missing refresh token on subsequent sign-ins.
When Google redirects back, Passport attaches both tokens to the user object, and serializeUser/deserializeUser 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.
The /events endpoint routes are protected by a small middleware in server/index.ts. Requests without an authenticated session receive a 401 response instead of reaching the route handlers.
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’s verification process.
Let’s see how the stored credentials are used to make actual Google Calendar API calls.
From Session Tokens to Requests
With tokens stored in the session, each outgoing request to Google carries them. The googleService.ts source file contains the server-side implementation of the JavaScript Google Calendar API integration, using the googleapis library to make requests.
For every operation, the oauthClent() 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:
const client = new google.auth.OAuth2(
config.GOOGLE_CLIENT_ID,
config.GOOGLE_CLIENT_SECRET,
config.GOOGLE_REDIRECT_URI
);
client.setCredentials({
access_token: tokens.accessToken,
refresh_token: tokens.refreshToken,
});
return client;
}
// ...
export async function createEvent(
tokens: GoogleOAuthTokens,
calendarId: string | undefined,
gEvent: calendar_v3.Schema$Event
): Promise<calendar_v3.Schema$Event> {
const { data } = await calendarClient.events.insert({
auth: oauthClient(tokens),
calendarId: calendarId || "primary",
requestBody: gEvent,
conferenceDataVersion: 1,
});
return data;
}
Two things in this snippet are easy to miss. If the browser does not specify a calendar, the entry lands in the account’s primary one. The conferenceDataVersion: 1 option enables support for conference data, including Google Meet details. Without it, the API silently ignores any conference data in the request.
Because the refresh token is passed to the client object, Google’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.
Now, we can proceed to the next step focused on how Scheduler communicates with the server.
The REST Contract
The server exposes four REST endpoints that match the request and response format Scheduler’s DataProcessor expects:
- 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.
- POST /events creates an entry and answers with the “inserted” action plus a tid value.
- PUT /events/:eventId saves changes and answers with “updated” plus tid.
- DELETE /events/:eventId removes an entry and answers with “deleted”.
The tid 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.
Loading Events from Google to Scheduler
In the browser, the DHTMLX JavaScript event scheduler opens in week mode, and dynamic loading is on:
scheduler.setLoadMode("week");
With the setLoadMode() 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’s timeMin and timeMax query parameters:
const toQuery = typeof req.query.to === "string" ? req.query.to : undefined;
const minDate = fromQuery ? new Date(fromQuery).toISOString() : new Date().toISOString();
const maxDate = toQuery ? new Date(toQuery).toISOString() : undefined;
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’s format, and the calendars list placed under collections. Scheduler remembers such collections, so later the client code can retrieve the list by calling the serverList() method with the “calendars” key, and the DataProcessor relies on precisely this.
Event conversion is implemented in a separate eventMapper.ts module, which holds most of the integration’s complexity. Google Calendar and Scheduler describe the same event in noticeably different ways:
TABLE!!!
The toDhxEvent() function copies the event’s title and description, records which calendar the event belongs to, and then branches by event type. Timed events get their start_date and end_date directly from Google’s dateTime values. All-day events carry dates only, so the mapper converts them to midnight timestamps.
Now you are familiar with how events reach Scheduler from Google Calendar. Next, we’ll consider how edits made in Scheduler reach Google Calendar.
Sending Edits Back to Google
The reverse synchronization flow includes a few noteworthy aspects, such as preparing event data for Google Calendar’s 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.
The DataProcessor as the Router
Scheduler sends every change a user makes (adding, editing, or removing an event) to a single callback function. The demo defines this callback in main.ts, where the client sets up Scheduler:
entity: "event",
action: SchedulerDataAction,
data: DhxEvent,
id: string | number
) => {
const calendars = scheduler.serverList("calendars") as MappedCalendar[];
data.calendarId = calendars[0]?.id;
data.timeZone = momentTz.tz.guess();
return fetchEvent(action, data, id);
});
Passing a function to createDataProcessor 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:
- calendarId 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.
- timeZone is the browser’s local time zone, detected by moment-timezone. The server uses it to format dateTime strings and to fill in the timeZone property on Google’s side.
The fetchEvent() method then maps the action to an HTTP request using the appropriate method, URL, and payload:
const requestConfigs = {
create: ["POST", "events/", data],
update: ["PUT", `events/${id}`, data],
delete: ["DELETE", `events/${id}`, data],
};
For updates and deletions, the URL includes the event’s ID. This parameter is used by the server to figure out whether a Google Calendar event should be modified or removed.
Preparing the Payload for Google
On the server, the toGoogleEventPayload() method converts the incoming Scheduler event into Google’s format. The key decision here is whether the event should include date-only fields or date-time fields:
dhx.duration === 24 * 60 * 60 ||
new Date(dhx.end_date).getTime() - new Date(dhx.start_date).getTime() === 24 * 60 * 60 * 1000;
An all-day event is sent with date fields only. Timed events get dateTime strings formatted by moment-timezone in the user’s time zone, along with an explicit timeZone.
Keep in mind that the condition checks whether dhx.duration 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.
Handling Time Zones Correctly
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 format_date template, and the result is a local time without any offset, for example “2026-10-16 17:00”. The server passes this string to new Date(), which interprets it as a time in the server’s time zone. Only then is the result converted to the user’s time zone.
During local development, the browser and the server share the same time zone, and the issue may go unnoticed.
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.
There are two possible fixes: make the client send dates with an offset (for instance, redefine scheduler.templates.format_date to return date.toISOString()), or let the server interpret incoming strings in the user’s time zone via moment.tz(value, timeZone).
Closing the Loop with Event IDs
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 tid:
Scheduler reads tid from the response and replaces the temporary event ID with Google’s 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’s server-side integration contract.
To update events, the server uses events.patch instead of events.update. The patch() method modifies only the properties included in the request, leaving the other ones intact.
Recurring Events
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.
Calculating the End of a Series
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 toDhxEvent() method takes the pattern from Google’s recurrence array, drops the “RRULE:” part, and calculates the duration of one instance from the first occurrence’s start and end. Finding the end date takes a separate helper, because Google stores it as the UNTIL parameter in the recurrence rule:
// if there is no UNTIL -> event repeat infinitely -> return '9999-02-01T00:00:00Z'
function calculateEndDate(gEvent: calendar_v3.Schema$Event): Date {
const until = String(gEvent.recurrence?.[0] ?? "").match(/RRULE:.*?UNTIL=([^;]+)/)?.[1];
return until
? new Date(
until.replace(/^([0-9]{4})([0-9]{2})([0-9]{2})T([0-9]{2})([0-9]{2})([0-9]{2})Z$/, "$1-$2-$3T$4:$5:$6Z")
)
: new Date(9999, 1, 1);
}
The calculateEndDate() method takes the UNTIL 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 new Date(9999, 1, 1) uses the server’s local time zone, so the Z in the comment is only correct when the server runs in UTC. For a “never ends” placeholder, this doesn’t matter in practice.
In the other direction, toGoogleEventPayload() adds the “RRULE:” prefix back. For a timed series, it also sets the end of a single occurrence to start_date plus duration. This is needed because Scheduler uses end_date to define when the entire series ends, not the end of one occurrence.
Modifying Occurrences
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 recurringEventId to recurring_event_id and originalStartTime to original_start, allowing Scheduler to identify which part of the original series is replaced by this exception.
Canceled occurrences have status: “cancelled”, which the mapper converts to deleted: true in Scheduler. The toGoogleEventPayload() function maps the corresponding fields back to Google’s format and converts deleted: true to status: “cancelled”.
For details on how Scheduler represents recurring event series and their modified occurrences, see the Recurring Events guide.
Deleting Recurring Events and Occurrences
Before forwarding a deletion to Google Calendar, the DELETE handler checks the event ID to decide whether the request is needed:
// 2) If this is an exception of a recurring event, there is nothing to delete on Google side.
// Google Calendar removes occurrences when deleting the main recurring event.
const dhxId = req.body?.id as string | undefined;
if (typeof dhxId === "string" && dhxId.indexOf("_") > -1) {
res.json({ action: "deleted" });
return;
}
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’t need to be deleted one by one.
Removing repeating entries produces these calls:
- Removing the whole series (the “All events” option) sends one DELETE request for the series.
- Removing an ordinary instance sends a POST to /events with deleted: true, recurring_event_id and original_start.
- Removing an instance that had been edited earlier sends the same POST followed by a DELETE for that exception.
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.
The POST request described above goes through toGoogleEventPayload() and becomes an events.insert call with a “cancelled” status, recurringEventId, and originalStartTime. However, the Google Calendar API reference marks recurringEventId 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 events.instances, then call events.patch or events.delete on it. Test this scenario on a real account, and if the occurrence isn’t removed, switch to this approach.
Local Demo Setup
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:
- Enable the Google Calendar API for your Cloud project.
- Configure the OAuth consent screen, keep the publishing status set to Testing, and add each Google account you plan to use as a test user.
- Create an OAuth client ID of the Web application type and add http://localhost:3000/auth/google/callback as an authorized redirect URI.
- Copy .env.example to .env and set the client ID, client secret, redirect URI, session secret, and port.
- Run npm install to install dependencies, start the app with npm run start, and open http://localhost:3000.
Check two environment settings before you start:
- If NODE_ENV is set to production, npm skips development dependencies, including tsx. Run npm install –include=dev to install them.
- If your shell already defines PORT, that value takes precedence over .env. Make sure the server port matches the port in your redirect URI.
The demo uses the @dhx/trial-scheduler trial package.
Clone the scheduler-google-calendar-demo repository to try the integration with your own Google Cloud project. For detailed setup instructions, see the official DHTMLX integration guide.
Final Thoughts
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.