ShootCal
Open web app View demo

HomeHelp › How ShootCal Is Built

How ShootCal Is Built

ShootCal is not one app. It is a small family of apps that all sit on top of one thing you already own: your Google Calendar.

There are three pieces:

Here is the important part for you: ShootCal does not keep its own copy of your schedule. Your bookings, sessions, and clients live in your Google account (your calendar, your contacts, your tasks). ShootCal is the photographer-friendly lens you look at them through. It adds the things Google does not: golden-hour timing, session types, deposit tracking, client booking pages, contracts, and an availability link.

Because the real data lives in Google, everything stays in sync automatically. Mark a deposit on your Mac and it shows on your phone, because both are really just reading and writing the same Google Calendar event. There is no separate "ShootCal account" holding your calendar.

The native phone and Mac apps keep a local copy of your recent months so the calendar opens instantly and you can still browse it with no signal. The web app is online-only and the Apple Watch simply mirrors a short list of upcoming sessions that your phone sends it.

The three surfaces and what each one is

Native apps (SwiftUI). One shared SwiftUI codebase targets iPhone, iPad, Mac, and Apple Watch, plus home-screen widgets. The Mac build is a single-window app with an optional menu-bar popover, while iOS uses the standard multi-window scene model. The app signs in with Google directly using Apple's GoogleSignIn SDK and talks to the Google Calendar API itself.

Web app (vanilla-JS SPA). shootcal.com/app/ is a hand-written single-page app. The entire SPA is one JavaScript file (about 5,000 lines: the month grid, agenda, day panel, clients, contracts, reports, settings). There is no framework and no build step. It never holds a Google token; it talks only to the server.

Backend (PHP 8 + SQLite). api.shootcal.com is a small, dependency-free PHP 8 app on a Hetzner server (CloudPanel/nginx). It has two halves: the original native availability-feed API (Sign in with Apple) and the much larger web app API (/v1/web/*) that proxies Google for the browser. Routing is a simple regex router; data is a single SQLite database.

Where the data actually lives

This is the key architectural fact: the events, clients, and to-dos are not stored by ShootCal. They live in Google.

What the backend's SQLite database does store, all scoped to your account, is the glue that has no Google home: your web account (identified by your Google sign-in), your hashed cookie sessions, your encrypted Google tokens, your settings, your availability-feed configuration, your booking pages and incoming booking requests, your contracts and packages, plus reviews, push subscriptions, and bug reports. Notably absent: a copy of your calendar events. The server fetches those live from Google every time.

How the browser app talks to the server (and never touches Google directly)

The web app is deliberately same-origin. shootcal.com's nginx proxies /api/ to the api.shootcal.com backend, so the browser only ever talks to shootcal.com. Every request the app makes goes to that same /api path and includes your session cookie.

The browser holds only an opaque session token in an HttpOnly + Secure + SameSite=Lax cookie. The server stores only a one-way hash of it. Your identity is resolved purely from that server-side session record; the browser never sends a user id or calendar owner that the server trusts. The Google OAuth flow runs server-side with PKCE, and the resulting refresh token is encrypted at rest with libsodium secretbox, keyed from a server-only secret outside the web root. So when the web app asks for events, the server refreshes that user's access token, calls Google, and returns normalized JSON. The browser never sees a Google token.

How the native apps talk to Google (a different path)

The native apps do not route calendar traffic through ShootCal's server. They call Google's API directly, hitting Google's calendar endpoints over the network, authorized by the Google access token the GoogleSignIn SDK manages. Creating, editing, deleting events, listing calendars, marking a deposit, and sending the deposit-paid email via Gmail: all straight to Google.

The native app does also reach api.shootcal.com for a set of cross-platform features that have no direct Google equivalent. For these it authenticates with the same Google access token it already holds: the server verifies the token was issued for ShootCal, maps it to the matching web account, and scopes everything per-user. These bridge calls include:

The two paths meet at Google Calendar. The Mac/iOS app writing an event and the browser reading it are operating on the same Google data, which is why they stay in sync without any direct app-to-app channel.

Sync, offline, and caching

The sync mechanism is Google Calendar itself. There is no ShootCal-run sync server pushing events between devices. Each surface independently reads/writes the same Google account, so a change made anywhere appears everywhere on the next fetch.

Native is offline-first for reading. The native apps keep a local copy of your events as per-month files in a shared on-device container, organized by Google account and calendar. On launch the app paints from this cache immediately, then refreshes from Google in the background. The cache is intentionally robust: a torn or changed file falls back to empty for just the affected section rather than forcing a full multi-year re-fetch. The same on-device cache feeds the iOS widgets.

There is no offline write queue. Edits go straight to Google; with no connection a create, edit, or delete fails rather than queuing. Offline support is for browsing your already-loaded schedule, not editing it.

The web app is online-only by design. Its service worker deliberately caches nothing; it exists purely to receive Web Push session reminders and keep the app installable as a PWA. HTML is never CDN-cached and assets are hash-busted on deploy, so there is nothing to go stale.

The Apple Watch is a mirror, not a client. The phone is the source of truth; it pushes a slim snapshot of up to the next 40 upcoming sessions (30-day horizon) to the watch over Apple's device-to-device connectivity. The watch stores that in its own on-device container and renders the list and complication. The watch never talks to Google.

The availability feed: the one place the server pre-builds something

Your public availability link is the exception to "the server stores no calendar data." The server reads your Google calendar and writes a privacy-stripped .ics file to disk, served statically at feed.shootcal.com/<token>.ics (and an iframe-able embed at api.shootcal.com/embed/<token>).

The feed builder enforces a strict privacy contract: only booked days are published (deposit-received by default, or optionally any busy event), and each calendar entry carries only an id, timestamps, and a busy/confirmed status. No titles, locations, descriptions, or attendees ever. A forbidden-field audit runs before anything is written and fails closed. The file is rebuilt on demand and by a scheduled job and Cloudflare-purged, so the link stays current even when no app is running. This mirrors the native app, which produces the same scrubbed feed for the original Apple-identity feed path.

What runs where, in one view

FAQ

Does ShootCal store my calendar on its own servers?

No. Your sessions are Google Calendar events, your clients are Google Contacts, and your to-dos are Google Tasks. The backend's SQLite database stores accounts, encrypted Google tokens, settings, booking pages/requests, contracts, and similar glue, but not a copy of your calendar events. The one exception is the public availability feed, which is a deliberately privacy-stripped .ics file (booked days only, no event text) that the server generates from your calendar.

How do the Mac app and the web app stay in sync if there is no ShootCal sync server?

They sync through Google Calendar. Both the native app and the web backend read and write the same Google account, so a change made on one surface appears on the others at the next fetch. There is no direct app-to-app sync. Some photographer data has no Google home, though: session types, home location, booking defaults, plus reports and contract status. Those sync through the backend's per-user rows, which the native app reaches using the Google access token it already holds (last write wins for settings).

Does the native app go through api.shootcal.com to reach my calendar?

No. The native apps call the Google Calendar API directly (googleapis.com) using the Google access token from the GoogleSignIn SDK. They do contact api.shootcal.com for cross-platform features that have no direct Google equivalent, authenticated with that same Google token: settings sync, the booking-requests inbox, and reading the shared reports, contracts, and client booking data. The browser app is the opposite: it never holds a Google token and reaches Google only through the backend proxy.

Where are my Google tokens kept, and how safe are they?

For the web app, your Google refresh and access tokens are stored on the server in SQLite, encrypted at rest with libsodium secretbox (XSalsa20-Poly1305) using a key that lives outside the webroot. A database leak alone yields no usable tokens. The browser never receives a Google token; it only holds an opaque session cookie whose SHA-256 hash is what the server stores. For the native apps, Google's own SDK manages the token on-device.

Can I use ShootCal offline?

On the iPhone, iPad, and Mac you can browse your recent months offline, because the native app caches events as per-month JSON in a shared app-group container and paints from that cache before refreshing from Google. Creating or editing events requires a connection, since writes go straight to Google with no offline queue. The web app is online-only by design; its service worker caches nothing and exists only for push reminders.

Why is the browser app same-origin instead of calling the API directly?

shootcal.com's nginx proxies /api/ to the api.shootcal.com backend, so the browser only ever talks to shootcal.com. That lets ShootCal use a first-party, HttpOnly + Secure + SameSite=Lax session cookie with no CORS. Your identity is resolved entirely from the server-side session row keyed to your Google account; the browser never sends a calendar owner the server trusts, which is what guarantees one user can never read another's calendar.

What stops my public availability link from leaking client details?

The feed builder publishes only booked days, and each event in the .ics carries only an id, timestamps, and a confirmed status. A fixed forbidden-field list (title, location, description, attendees, and more) is audited before the file is written, and the build fails closed if any forbidden line is present. Event titles, locations, and attendees can never appear in the feed.

How does the Apple Watch get my schedule?

From your iPhone, not from Google. The phone pushes a slim snapshot of up to your next 40 upcoming sessions (within 30 days) to the watch over Apple's device-to-device connectivity. The watch stores it in its own on-device container and renders the list and complication. The watch app never authenticates to Google or fetches the calendar itself.

Is there a build step or framework behind the web app?

No. The SPA is a single hand-written vanilla-JavaScript file (around 5,000 lines) with no framework and no bundler. Deployment copies the static site files up and purges the Cloudflare cache; assets are hash-busted with a ?v= query so the always-network-fetched HTML never serves stale code. The backend is similarly dependency-free PHP 8, using only PHP built-ins.

← Back to Help Center