Fixing Calendar Sync Across Multiple Booking Platforms: OAuth Token Lifecycle & Automation Trigger Failures

Over the past week, we discovered a cascading failure in our multi-platform calendar synchronization pipeline that prevented bookings from auto-syncing to our internal calendar system. This post details the root causes, the infrastructure decisions that led to them, and the permanent fix we implemented.

The Problem: Three Layers of Failure

A booking came through Boatsetter (which has instant booking enabled via iCal) on May 30th, but it never appeared in the JADA Internal calendar. Investigation revealed three distinct failure modes:

  • Trigger Never Activated: The Boatsetter iCal sync code existed in CalendarSync.gs within our Google Apps Script project, but the setup function calendarDashboardSetup() had never been executed, leaving the scheduled trigger in a dormant state.
  • Gmail OAuth Token Expired: The Gmail OAuth scope token (indexed as t-8d86d5ba in our token management system) had hit its 7-day refresh limit, rendering calendar write operations impossible.
  • OAuth App in Testing Mode: The root cause: our Google Cloud OAuth app was never published from "Testing" to "Production" status, which hard-caps refresh token lifetime at 7 days instead of the standard 6-month lifecycle.

Technical Architecture & Root Cause Analysis

Google Apps Script Calendar Sync Pipeline

Our calendar synchronization is built on Google Apps Script (GAS), with the core logic in /Users/cb/Documents/repos/tools/CalendarSync.gs:

function calendarDashboardSetup() {
  // Activates scheduled triggers for:
  // - Boatsetter iCal fetch (pulls from configured iCal URL)
  // - Sailo iCal fetch (same mechanism)
  // - Viator API polling (custom endpoint)
  // Creates time-based triggers that run every 30 minutes
}

This function must be invoked once in the GAS editor UI to establish the initial triggers. These triggers are persisted in the Apps Script project's trigger store, not in code. We discovered this setup was never executed, leaving the sync logic compiled but unreachable by any scheduler.

The Token Lifecycle Problem

We maintain OAuth tokens in `/Users/cb/Documents/repos/tools/token_management/` with scope tracking:

  • gmail_token.json — Google Gmail API scope (read/write for sending booking confirmations)
  • calendar_token.json — Google Calendar API scope (required for all sync operations)
  • sheets_token.json — Google Sheets API scope (for data validation)

The calendar token had been refreshed via our reauth_jada_all.py script multiple times, but each time it was regenerated under the "Testing" OAuth app configuration. Google's OAuth 2.0 specification enforces a 7-day absolute refresh token lifetime for apps in Testing mode as a security measure, with no way to extend it without publishing the app to Production.

Why This Happened: The Testing Mode Trap

When we initially set up the OAuth consent screen in Google Cloud Console, the app was left in "Testing" status. This is standard for development, but it's a trap: the status change to "Production" requires:

  • A privacy policy URL (we deployed one to sailjada.com/privacy)
  • An app logo
  • A list of scopes requiring user consent (Gmail, Calendar, Sheets — all user-facing data)
  • Google's automated review process (typically 1-3 days)

Previous efforts to "fix" the recurring token expiration involved re-running reauth_jada_all.py, which is a band-aid: it generates a new token that will expire in exactly 7 days again. This cycle repeats indefinitely under Testing mode.

The Permanent Fix: OAuth App Publication & Trigger Activation

Step 1: Publish OAuth App to Production

Using the Google Cloud Console UI (API-only configuration is incomplete), we:

  • Updated the OAuth consent screen with our privacy policy URL: https://sailjada.com/privacy
  • Set the app status from "Testing" to "In Production"
  • Completed Google's automated compliance review

Once published, any new tokens generated will have a 6-month refresh token lifetime, eliminating the 7-day expiration cycle.

Step 2: Regenerate Tokens Under Production App

Using our authentication script:

# The reauth script handles the OAuth 2.0 authorization code flow
# It prompts for manual browser-based consent (required for scopes involving user data)
python3 reauth_jada_all.py

# Generated tokens are stored in:
# - ~/.config/jada/calendar_token.json
# - ~/.config/jada/gmail_token.json
# - ~/.config/jada/sheets_token.json
# With metadata tags: production_app=true, generated_date, scope_list

These new tokens are now backed by the Production OAuth app configuration, granting them the 6-month refresh window.

Step 3: Sync Tokens to Lambda & GAS Environment

Our calendar sync runs on two execution environments:

  • AWS Lambda: calendar-sync-jada function in us-east-1, triggered by CloudWatch Events every 30 minutes. Token pushed via: aws lambda update-function-environment --function-name calendar-sync-jada --environment Variables={GOOGLE_CALENDAR_TOKEN=...}
  • Google Apps Script: Token injected via Properties Service in the GAS project, accessible via PropertiesService.getUserProperties().getProperty('CALENDAR_TOKEN')

Step 4: Activate the GAS Triggers

Invoked calendarDashboardSetup() in the Apps Script editor, which created persistent triggers:

  • calendarSyncBoatsetter_Trigger — runs every 30 minutes, fetches Boatsetter iCal feed, parses events, writes to JADA Internal calendar
  • calendarSyncSailo_Trigger — same pattern for Sailo iCal feed
  • calendarSyncViator_Trigger — polls Viator API endpoint (custom webhook-based sync, separate from iCal)

Multi-Platform Blocking & Manual Intervention

Until all sync triggers are active and tested:

  • Boatsetter: Auto-syncing now; bookings flow through iCal → GAS pipeline
  • Sailo: Same iCal mechanism; now active after trigger fix
  • Viator: API integration pending response from their team (ticket t-ad4b92d7); manual blocking in supplier.viator.com required until integration complete
  • GetMyBoat: Listing not yet live; no blocking needed

Key Decisions