Home › Help › How ShootCal syncs with Google Calendar
How ShootCal syncs with Google Calendar
ShootCal does not keep its own copy of your bookings. Your Google Calendar IS the database. Every session you see in ShootCal, on the Mac, iPhone, iPad, and the web app, is read live from Google Calendar, and every session you create, edit, move, or delete is written straight back to Google.
That has a few nice consequences:
- Anything you book in ShootCal shows up in the regular Google Calendar app, and anything you add in Google Calendar shows up in ShootCal. There is no separate "ShootCal calendar" to keep in sync.
- If you stop using ShootCal tomorrow, every booking is still sitting safely in your Google Calendar exactly as you left it. Nothing is trapped in our system.
- When you mark a deposit as received, ShootCal writes a tiny private note onto that Google event. It is invisible in the normal Google Calendar app and is never shown to your client, even if the client is invited to the event. It only exists to color the day green in ShootCal and keep your Mac, phone, and the web in agreement.
By default ShootCal uses your "primary" Google calendar, but you can point it at any calendar you own, and it can show several at once. The only things ShootCal stores on its own servers are your encrypted Google login token (so it can talk to Google on your behalf) and, if you turn on the public availability page, a stripped-down read-only feed of your booked dates. Your actual event details live in Google.
The core idea: Google Calendar is the only event store
There is no events table anywhere in ShootCal. The backend database has tables for users, encrypted Google tokens, the public availability feed, booking requests, contracts, reviews, and so on, but nothing that holds the content of a calendar event. A handful of rows reference an event by its Google event ID (for example a booking request stores the Google event id once it is accepted, and the AI client-name scan records which historical events it has already processed). Some of these rows do persist a little event-derived bookkeeping alongside the ID, the scan record, for instance, also stores the matched client name, session-type name, and event date so Reports can count it. What never gets copied into our database is the event's actual content: title, description, notes, attendees, and recurrence all stay in Google.
What lives on the clients
The native apps keep a per-month cache and the web app keeps an in-memory month cache, but both are throwaway snapshots of what Google returned, used only for fast painting and the iOS/widget warm start. The web cache is wiped on every edit (after a create, edit, delete, or deposit toggle) and the data is re-fetched from Google. Signing out on native purges every cache. Neither cache is ever treated as authoritative.
OAuth scopes used
Both surfaces request the same two Calendar scopes plus a few feature scopes.
Web sign-in: basic identity (openid, email, profile), Calendar events and read-only Calendar, Contacts, Gmail send, and a Drive scope for the contract archive.
Native sign-in: Calendar events, read-only Calendar, Contacts, and Gmail send.
The two that matter for sync are read-only Calendar (list calendars and read events) and Calendar events (create, edit, and delete events). These are both "Sensitive" scopes, not "Restricted", so there is no full-Drive-style review burden. ShootCal never asks for Google's full-access calendar scope, only the narrower events + read-only pair.
A couple of scopes are deliberately requested incrementally (only when you first use the feature) rather than at sign-in: the still-unverified Tasks scope and the read-only "Other contacts" scope. That keeps a normal sign-in off Google's unverified-app warning screen. The Gmail-send and Contacts scopes are bundled at sign-in on both surfaces because they are already verified.
Which calendar gets read and written
The default everywhere is the calendar named primary. On the web, a read uses the calendar you ask for and otherwise falls back to primary; create, edit, and delete do the same. The web grid actually fans out across every calendar in your list, requesting each calendar separately and merging the results; the enabled/disabled toggle is then applied when the events are drawn (calendars you have switched off are skipped), not at fetch time.
The native apps support multi-calendar mode too, and there the fan-out only requests the calendars the caller passes in (the enabled set). Because Google's event payloads do not tell you which calendar an event came from (the calendar is only implied by the request), each event is "stamped" with its source calendar id right after fetch. Every write path then targets that stamped id so editing or deleting an event always hits the calendar it actually lives on, even when several calendars are merged into one view.
Reading events
Reads ask Google to expand recurring series into individual instances and return them in chronological order. Both the web and native request the maximum page size (2500) and follow Google's "next page" token so a long window (for example a 3-year availability feed) is never truncated to a single page, which, combined with start-time ordering, would otherwise silently drop the far-future weddings. The web caps at 12 pages, native at 40, purely as runaway guards. The web read also explicitly requests only the fields it needs, including the extended properties, so the deposit flag comes back.
On the web, every read first resolves the user from the session cookie and uses only that user's stored, encrypted Google token; the browser never receives a Google access token. The web events endpoint normalizes each Google event down to a small shape (id, title, start, end, all-day flag, location, description, color, status) plus the computed paid and has-deposit flags.
Writing events (create / edit / delete)
Writes are ordinary Google Calendar calls: create adds a new event, edit applies a partial update, and delete removes the single event.
A few details worth knowing:
- All-day events use Google's exclusive-end-date model: a one-day all-day event is stored as start = the day, end = the next day. Both surfaces handle this explicitly.
- The native delete always tells Google not to send attendee notifications. Without that, Google rejects deletes of events that have attendees or of a recurring master with edited instances, which is what made "Delete Entire Series" silently fail. Create and update follow the same explicit pattern when attendees are being touched.
- Native deletes also pass through a single rate-limiter (40 deletes per rolling 60 seconds). It is the one choke point for every delete, so no bug or loop can mass-wipe a calendar; a tripped breaker self-heals as old deletes age out. Deleted Google events also land in Calendar Trash (recoverable about 30 days).
- When you create a session in ShootCal, the app stamps two more private properties onto the event in addition to deposit: the session-type name and the client name. These are the durable source of truth for type and client, so renaming the event title never loses them, and Reports reads them. A web-created event that DOES have a session type is also stamped to stay ON the public feed; one created with no session type is stamped to stay OFF it, so personal events never appear publicly.
How the deposit flag is stored on the event
The deposit status is stored as a per-event extended property in Google Calendar's PRIVATE bucket, set to either "paid" or "unpaid".
The "private" bucket is the important part. Google has two extended-property buckets, private and shared. Private properties are visible only on the organizer's own calendar and never propagate to attendees' copies of the event. So even when you invite the client to the event (to save their email or RSVP), the client never sees "paid" or "unpaid" on their copy, and it does not appear anywhere in the normal Google Calendar UI. It round-trips through Google's API and follows the event across all the photographer's devices.
Reading the flag is centralized on each surface so everything agrees, though the two readers are not byte-for-byte identical. The native reader accepts "paid" as paid and a few spellings of "not paid"; the backend additionally accepts some older shorthand values for events written by earlier web versions. The key difference is the fallback: the native app now reads the flag as its SOLE source of truth (the old Google-color fallback and an even older "Paid - " title prefix were both removed there after a one-time backfill stamped the modern flag onto every historical paid event). The backend, however, still keeps a color fallback: when the flag is absent, it treats the green deposit color as paid and the red one as not paid. Only the legacy "Paid - " title prefix was removed from the backend reader. This matters because the public availability feed and the web's paid flag both go through that same backend reader, so a historical event that has the green color but no flag still counts as paid on the web/feed side.
Writing the flag: the web sends a partial update that sets the private deposit property. Native does the same and also flips the event's Google color between green (Basil) and red (Tomato), but ONLY for events that already use a deposit-style color, so it never overwrites a custom color you picked. The event title is never modified by the deposit toggle on any surface.
What is actually stored locally vs in Google
Stored on ShootCal's servers (a SQLite database): your account record, your Google refresh and access tokens (the refresh token and the cached access token are encrypted at rest with libsodium secretbox, so a database leak alone exposes no usable token), the calendar id your public availability feed points at, booking-request rows (which reference a Google event id once accepted), a little Reports bookkeeping for scanned/imported sessions (client name, type, and date, but not the event's title or notes), contracts, reviews, and similar metadata. The access token is short-lived and refreshed server-side from the encrypted refresh token as needed.
Stored in your browser (web): only UI state in localStorage, the active tab, month-vs-agenda view, which calendars are enabled, theme, and whether you dismissed onboarding. No event content is ever persisted client-side; the month cache is in-memory only.
Stored on your devices (native): a per-month snapshot of fetched events in the app-group container, namespaced by Google account email and calendar id so a different account can never load the previous account's cache. It is purged on sign-out.
The one place event-derived data is persisted as a file is the optional public availability feed: a privacy-stripped, read-only calendar feed built from your Google calendar. By default it includes only deposit-received events and strips titles/details, so the embed only ever broadcasts "this date is booked," never who or what. (A per-feed "all busy events" mode exists too, but it still carries no event text, only busy blocks.)
Everything else, every title, time, location, note, attendee, recurrence, and the deposit flag, lives in Google Calendar.
FAQ
If Google Calendar is the database, what happens to my bookings if I stop using ShootCal?
They stay exactly where they are, in your Google Calendar. ShootCal never copies an event's actual content (title, description, notes, attendees) into its own database, so there is nothing to export and nothing to lose. Every session is a normal Google Calendar event you can open in the regular Google Calendar app. The only ShootCal-specific data on the event is a handful of private extended properties (the deposit flag, the session type, and the client name), which is harmless metadata that the rest of the world ignores. (ShootCal's own database keeps some lightweight bookkeeping for Reports, a client name, session type, and date for scanned sessions, but that is a small derived summary, not your calendar.)
Can my client see whether I marked their deposit as paid?
No. The deposit status is stored as a private extended property on the event. Google's private extended-property bucket is visible only on the organizer's (your) calendar and is never propagated to attendees' copies of the event. So even if you invite the client to the event, their copy never carries the flag, and it is invisible in the normal Google Calendar UI on both sides. It exists purely so ShootCal can color the day and keep your devices in agreement.
What OAuth scopes does ShootCal need just for calendar sync, and does it get full calendar access?
For sync specifically it uses two scopes: read-only Calendar to list calendars and read events, and Calendar events to create, edit, and delete events. It does NOT request Google's broad full-access calendar scope. Both scopes are classified by Google as Sensitive, not Restricted. (The full sign-in grant also includes contacts and Gmail send for the client-manager and confirmation-email features, but those are separate from calendar sync.)
Which calendar does it use, and can I use more than my primary one?
The default is the calendar named primary everywhere. But you can point ShootCal at any calendar you own, and it supports multiple calendars at once. On the web the grid fetches each calendar in your list separately and merges them, applying your enabled/disabled toggles when it draws the events; the native apps fetch the enabled calendars and merge them. Because Google does not tell you which calendar an event came from in the event payload, ShootCal stamps each event with its source calendar id at fetch time so edits and deletes always target the correct calendar.
How does ShootCal avoid dropping far-future events like long-lead weddings?
It paginates. Calendar reads expand recurring series, sort by start time, request the maximum page size (2500), and follow Google's "next page" token to the end. If it only read the first page, the start-time ordering would mean the events it dropped would be the farthest-future ones, exactly your long-lead weddings. The web caps at 12 pages and native at 40 as runaway safety nets, well beyond any real calendar. On the web, if any page of a multi-page read fails, it returns failure for the whole read rather than republishing a truncated list.
Is the deposit color (green/red) the source of truth for paid status?
It depends on the surface. In the native apps, no: paid status is read solely from the private deposit extended property, and the Google-color fallback (and an even older 'Paid - ' title prefix) were removed there after a one-time backfill. On the backend/web, the flag is still the primary source, but a color fallback remains: if an event has no flag, the backend still treats the green deposit color as paid and the red one as not paid (only the title-prefix reader was removed). That fallback also drives the public availability feed. Either way, the native deposit toggle still flips green and red as a visual convenience, but only for events that already use those deposit colors, so it never clobbers a custom color you chose.
Does the local cache ever override what's in Google?
No. The native per-month cache and the web in-memory month cache are display optimizations only. The web clears its cache and re-fetches from Google after every create, edit, delete, or deposit change, and the native cache is purged on sign-out. Google is always re-read as the authority; the caches just make navigation feel instant and let the iOS widget paint without a network round-trip.
What does ShootCal actually persist on its own servers?
Your account row, your Google refresh and access tokens (encrypted with libsodium secretbox, refreshed server-side as needed), the calendar id your public availability feed points at, booking-request and booking metadata (which reference a Google event id but not the event's title or notes), a small Reports summary for imported/scanned sessions (client name, type, date), contracts, and reviews. The only event-derived file it writes is the optional public availability feed, which is privacy-stripped and by default lists only your booked (deposit-received) dates with no titles or details.