Google integration
Connect a Google account so Calendar events become meetings and Gmail metadata becomes an email timeline.
Connects a Google account to a workspace and syncs Calendar events into meetings and Gmail metadata into an email timeline.
Sections 1 and 2 are done once, by you, in the Google Cloud Console. The resulting OAuth client is then pasted into Settings → Integrations, per workspace — it is not an environment variable.
1. What to enable in the Cloud Console
Create the project
- Go to https://console.cloud.google.com/ and create a project (or pick one).
- Note the project name — you will select it in every step below.
Enable the APIs
APIs & Services → Library, then enable each of:
| API | Needed for | Skip if |
|---|---|---|
| Google Calendar API | Syncing events into meetings | You only want email |
| Gmail API | The email timeline | You only want calendar |
| Google People API | Reading the linked account's name and email | Never — always enable |
The People API backs the
userinfocall that identifies which Google account was linked. Without it the callback cannot tell one account from another and the connection fails.
Configure the OAuth consent screen
APIs & Services → OAuth consent screen
-
User type: Internal if everyone using this CRM is in your Google Workspace organisation. Choose it if you can — it skips Google's verification entirely. Otherwise External.
-
App name, support email, developer contact — required.
-
Authorised domains — the domain serving the app.
-
Scopes — add:
https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/calendar.events https://www.googleapis.com/auth/gmail.readonly ← only if you want email -
Test users — while the app is in Testing, only addresses listed here can connect. Add your own.
Create the OAuth client
APIs & Services → Credentials → Create credentials → OAuth client ID
-
Application type: Web application
-
Authorised redirect URIs — add one per environment, exactly:
http://localhost:3000/api/integrations/google/callback http://hexabase:9031/api/integrations/google/callback https://your-production-host/api/integrations/google/callbackThese must match character for character, including the scheme and port. A mismatch produces
redirect_uri_mismatchand nothing else.
Copy the Client ID and Client secret.
2. Read this before enabling Gmail
gmail.readonly is a restricted scope. For an External app, Google
requires a security assessment (an annual third-party audit, with real cost and
weeks of lead time) before it can be verified. Until verification, an External
app is capped at 100 users and shows an "unverified app" warning.
Internal apps are exempt. If your users are all in one Google Workspace organisation, set the consent screen to Internal and this does not apply.
Calendar (calendar.events) is a sensitive, not restricted, scope — it needs
verification for External apps but no security assessment.
If Gmail is not worth that, connect calendar only: the app requests Gmail scopes only when you tick the box, and every Gmail feature degrades to absent rather than broken.
3. Where the credentials go
In Settings → Integrations, not in the environment. Open the workspace, choose Integrations, and fill in the Google OAuth client section:
| Field | Value |
|---|---|
| Client ID | ...apps.googleusercontent.com |
| Client secret | from the same Cloud Console credential — stored encrypted, never sent back to the browser |
| Redirect base URL | the origin Google redirects back to: the redirect URI above, minus /api/integrations/google/callback. Leave empty to use BETTER_AUTH_URL. |
Save takes effect immediately — no restart, and no rebuild of the image.
An OAuth client is a single application to Google: one consent screen, one quota, one publishing status, one set of test users. A server-wide client would therefore put every workspace's calendar and mailbox behind credentials no individual tenant could rotate or revoke, and a quota exhausted by one workspace would stop the others. That is why these are per workspace.
The environment still owns one Google-related value, because it protects the tokens rather than being one:
# Optional. Encrypts stored OAuth tokens at rest; falls back to
# BETTER_AUTH_SECRET. Any length — it is hashed to 32 bytes.
GOOGLE_TOKEN_ENCRYPTION_KEY=It is not PB_-prefixed, so it never reaches the browser. See
src/lib/public-env.ts.
Migrating an existing deployment
Installs that predate this read GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET and
GOOGLE_REDIRECT_BASE_URL from the environment. Nothing reads them now, so
copy them into each workspace's settings once:
npx tsx db/seed-google-settings.ts --check # report, change nothing
npx tsx db/seed-google-settings.ts # writeIt is idempotent, skips any workspace that already has a client id, and does nothing at all when the two variables are unset. Connected accounts survive the move — their stored grants are untouched — but until the client is present in settings, no token can be refreshed, so run it before or just after deploying. Afterwards the three variables can be deleted.
4. Connecting an account
- Open Settings in the workspace.
- Under Integrations, choose Connect Google (tick Include Gmail to request mailbox access).
- Approve the consent screen. You are returned to Settings.
- Press Sync now to pull immediately, rather than waiting for the worker.
Requires the workspace:manage permission — owners only, at present.
5. How the sync behaves
Calendar → meetings. One-way. Events from the primary calendar become
meetings, matched on the Google event id so a re-sync updates rather than
duplicates. Cancelling in Google deletes the meeting here. The first run pulls
30 days; every run after is incremental via Google's syncToken. If that token
expires (410), it falls back to a full window automatically.
Gmail → email timeline. Headers and Google's own snippet only — never the message body. Messages are linked to a contact by matching the counterparty address, and to that contact's primary company. A message from someone not yet in the CRM is still stored, and becomes linked as soon as they are added and the next sync runs.
What triggers a sync
- Sync now in Settings.
- The
calendar-sync-queueBullMQ worker (npx tsx workers/index.ts), which needsREDIS_URL. - A Google push notification, if the app is on public HTTPS.
Push notifications need public HTTPS. Google will not deliver to http://
or to a host it cannot reach, so watchCalendar is a deliberate no-op on a
local or plain-HTTP deployment and sync falls back to polling. That is why the
current :9031 deployment will not receive webhooks.
6. When it does not work
| Symptom | Cause |
|---|---|
redirect_uri_mismatch | The URI in the console differs from the request. Check scheme, host, port, path — and the workspace's Redirect base URL, which overrides BETTER_AUTH_URL when set. |
| "Google is not configured for this workspace" | The client ID or secret is missing from Settings → Integrations for that workspace. Each configures its own; a second workspace does not inherit the first's. |
access_denied | The account is not in Test users while the app is in Testing. |
| Connects, then stops after ~an hour | Google returned no refresh token. Revoke access at https://myaccount.google.com/permissions and reconnect — the app always asks with prompt=consent, which forces a new one. |
| "no usable token — reconnect it" | GOOGLE_TOKEN_ENCRYPTION_KEY (or BETTER_AUTH_SECRET) changed after the tokens were stored, so they can no longer be decrypted. Reconnect. |
| Calendar syncs, Gmail does not | The account granted only calendar scopes. Reconnect with Include Gmail ticked. |
| Webhook never fires | Expected on plain HTTP — see above. Polling still works. |
7. Security notes
- Tokens are encrypted at rest (AES-256-GCM) in
connected_accounts. A database dump alone does not yield a usable Google grant. - So is the client secret, in
organization_settings, under the sameCREDENTIALS_ENCRYPTION_KEY. It is never sent back to the browser — the settings page shows that it is stored, not what it is. - The credentials are read after the membership join, not before, in both
OAuth routes: reading a workspace's OAuth client is itself something only a
member with
workspace:managemay do. - The callback is CSRF-protected. A nonce is stored in an httpOnly cookie
when the flow starts and must match the one in
state, so a callback URL cannot be handed to a signed-in user to attach someone else's Google account to their workspace. - The org slug in the OAuth flow is untrusted — membership is re-proved on the callback, as everywhere else.
- Disconnecting deletes the stored tokens but does not revoke the grant at Google. To fully revoke, use https://myaccount.google.com/permissions.