Logo

Calendar Invites from a Bot, Without Domain-Wide Delegation

6 min read

Table of Contents

Intro

The proposal intake automation had one manual step left. After processing a submission, the bot would post: "Please add @alice and @bob to the review meeting on {date}." A human then opened Google Calendar and clicked through the invite flow. Every single time.

This automation closes that loop: the bot resolves the mentioned users to email addresses, finds the review meeting on the calendar, and sends real Google Calendar invites itself.

Sounds like a ten-line change on top of the calendar event creation from earlier in this series. It wasn't, because of one Calendar API rule.

The Wall: Service Accounts Cannot Invite People

The bot already creates calendar events with a service account, so my first attempt was to reuse it and patch the event's attendee list. The API refused:

1Service accounts cannot invite attendees without Domain-Wide Delegation of Authority.

This is a deliberate restriction. Inviting attendees sends emails on someone's behalf, and Google only allows a service account to do that if it has domain-wide delegation: workspace-admin-granted permission to impersonate any user in the domain.

For an internal bot that needs to touch exactly one recurring meeting, that's a comically oversized hammer. Delegation is granted per scope, not per calendar, so approving it would let the bot act as anyone on any calendar. I didn't want to own that, and the workspace admins wouldn't want to grant it.

Acting as a Real User Instead

The workaround: don't be a service account. Authenticate as an actual user with an OAuth refresh token, and patch the event on that user's calendar copy.

The subtle part is that the token user doesn't need to own the meeting. An invited guest can add other guests through the API, as long as the event's "Guests can invite others" setting is on (which is the Calendar default). So any attendee of the recurring meeting can be the token user.

Building credentials from a stored refresh token is short:

1from google.oauth2.credentials import Credentials as UserCredentials
2
3def _load_user_calendar_credentials() -> Optional[UserCredentials]:
4 raw = os.environ.get(CALENDAR_OAUTH_ENV)
5 if not raw:
6 return None
7 parsed = json.loads(raw)
8 return UserCredentials(
9 token=None,
10 refresh_token=parsed["refresh_token"],
11 client_id=parsed["client_id"],
12 client_secret=parsed["client_secret"],
13 token_uri="https://oauth2.googleapis.com/token",
14 scopes=CALENDAR_SCOPES,
15 )

The refresh token is minted once, locally, with a small helper script that runs the installed-app OAuth flow in a browser and prints the resulting JSON (client_id, client_secret, refresh_token). That JSON goes into Secret Manager, and Cloud Run mounts it as an env var. Returning None when the env var is absent doubles as the feature flag: no secret, no behavior change.

Finding the Right Event

The submission form carries the planned meeting date, so the obvious query is "events on that day whose title contains the meeting keyword." That worked until the first holiday week, when the meeting moved from Tuesday to Wednesday while the form submissions kept the planned Tuesday date.

So the search uses the whole Monday-to-Sunday week around the requested date and takes the instance closest to it:

1week_start = day - timedelta(days=day.weekday())
2time_min = datetime.combine(week_start, time(0), tzinfo=jst)
3
4response = calendar.events().list(
5 calendarId=MTG_CALENDAR_ID,
6 timeMin=time_min.isoformat(),
7 timeMax=(time_min + timedelta(days=7)).isoformat(),
8 q=MTG_EVENT_KEYWORD,
9 singleEvents=True,
10 orderBy="startTime",
11).execute()
12
13candidates = [
14 item for item in response.get("items", [])
15 if MTG_EVENT_KEYWORD in (item.get("summary") or "")
16]
17event = min(candidates, key=lambda item: _distance_from(item, day))

Two details: singleEvents=True expands the recurring meeting into concrete instances (you patch an instance, not the series), and the keyword is re-checked against the summary because q= does free-text matching over more fields than the title.

Sending the Invites

Slack mentions become emails via users_info (the profile's email field), then the attendee lists are merged with a case-insensitive dedup and patched in one call:

1attendees = list(event.get("attendees") or [])
2existing = {(a.get("email") or "").lower() for a in attendees}
3new_attendees = [
4 {"email": e} for e in emails if e.lower() not in existing
5]
6
7calendar.events().patch(
8 calendarId=MTG_CALENDAR_ID,
9 eventId=event["id"],
10 body={"attendees": attendees + new_attendees},
11 sendUpdates="all",
12).execute()

sendUpdates="all" is what turns a silent attendee-list edit into actual invitation emails. Without it the new attendees appear on the event but never hear about it.

Every Failure Falls Back, Nothing Blocks

The invite is a nice-to-have bolted onto a critical path (the submission itself), so the whole thing is wrapped in a single try/except that returns a boolean:

1def _try_invite_mtg_participants(data, client, logger) -> bool:
2 if not data.mtg_date or not data.mtg_participants:
3 return False
4 try:
5 mentioned = set(_USER_MENTION_PATTERN.findall(data.mtg_participants))
6 emails = resolve_user_emails(data.mtg_participants, client)
7 if not emails or len(emails) < len(mentioned):
8 return False
9 return get_proposal_service().invite_to_mtg(data.mtg_date, emails)
10 except Exception as e:
11 logger.warning(f"MTG invite failed; falling back to the manual ask: {e}")
12 return False

True means the processing message says "invited ✅". False, for any reason (missing secret, unparsable date, event not found, API error, unresolvable users), means the message stays the old manual ask. The submission is never blocked, and the worst case is exactly what happened before this feature existed.

The len(emails) < len(mentioned) guard exists because the success message claims everyone was invited. If three people were mentioned and only two resolved to emails, saying "invited ✅" would silently strand the third. Partial success is treated as failure, and a human handles it.

Gotchas

1. The Slack scope that fails silently

Reading a user's email from users_info requires the users:read.email bot scope. Without it the API call succeeds, ok is true, and the profile simply has no email key. Thanks to the fallback design nothing breaks, but every invite quietly downgrades to the manual ask, which took me a while to notice. If invites "never work," check the scope before the code.

2. Create the secret before merging the deploy change

The deploy workflow mounts the new Secret Manager secret into Cloud Run, and Cloud Run fails the whole deployment if a referenced secret doesn't exist. So the secret has to exist before the workflow change lands, even though the feature is designed to no-op without it. Ask me how I know.

3. The refresh token is a person

The token user is a real account. If that person leaves or their token is revoked in a security sweep, invites fall back to the manual ask (gracefully, but silently). Worth a note in the runbook about whose token it is and how to re-mint it.

Wrapping Up

The Calendar API's service-account restriction looks like a dead end, but "an invited guest may add guests" turns out to be all the permission an internal bot needs. One user's refresh token, scoped to one calendar's worth of trust, replaces domain-wide impersonation rights.

And because the invite path returns a boolean instead of throwing, shipping it was low-stakes: the feature flag is the existence of a secret, and every failure mode lands on the exact behavior the team already had.

Project Navigation

  1. 1.Private DMs from a Slack Workflow Without the Webhook Step
  2. 2.From a Slack Form to a Spreadsheet Row and a Calendar Event
  3. 3.Auto-Routing a Report to the Right Owner with a Spreadsheet Lookup
  4. 4.Letting an AI Agent Open the Pull Request
  5. 5.Calendar Invites from a Bot, Without Domain-Wide Delegation