Home › Help › Online booking and availability
Online booking and availability
ShootCal gives you two public things that work together: a shareable availability calendar and a booking page.
The availability calendar shows which days are open, limited, or fully booked. It is private by design. It never shows who booked you, what the shoot is, or where it is. It only ever publishes a day as booked once that session's deposit is marked received, so an empty-looking calendar stays empty until a real, paid booking exists. You can embed this calendar on your own website (Squarespace, Wix, etc.) by pasting an iframe, or share its link directly.
When a client picks an open day, they land on your booking page. They choose a session type, pick a date, and see real open times for that day. For your golden-hour types, those times are calculated from the actual sunset (or sunrise) at your location for that exact date, stacked back-to-back up to your per-day limit. For studio types, they see your fixed hourly slots.
By default, a submission does not touch your calendar. It creates a request that lands in your Booking Requests inbox (and emails you, unless you choose to handle requests only in the app). You review it and either Accept (which creates the calendar event right then) or Decline. There is also an optional instant-booking mode, set per session type, where the picked time is confirmed and added to your calendar immediately with no manual step.
A few built-in protections: a hidden anti-bot field, a 5-requests-per-hour-per-IP rate limit, and a re-check at accept time that the slot is still free so you never double-book.
The two public surfaces, and the URLs behind them
There are two distinct public things, served separately:
- The availability calendar (read-only). Served at
/embed/<token>and rendered to standalone HTML. Its data source is a stored calendar feed file on disk, built from your Google calendar. The embed reads that file directly: no database lookup for the events, and no live Google call when it renders. There is also a raw feed URL (the same file, served as a static.ics) for calendar subscriptions. - The booking page (interactive). Served at
/book/<token>, with a companion endpoint for a day's open times and a submit endpoint. The page can be addressed by a random token or by a custom slug (a 'pretty' handle). The booking link surfaced to you isshootcal.com/book/<handle>.
The embed and the booking page are connected: when the calendar owner has booking enabled, the embed makes day cells clickable. Open days link straight to that date on the booking page; a fully booked day links with a flag so the booking page can say 'this date is full, but if your dates are flexible I can try to fit you in.'
Why the availability calendar can never leak client details
The privacy guarantee is enforced where the feed is built, not at display time. Two mechanisms:
1. Only booked days are published. A day is 'booked' only if its session's deposit is received. That check reads a private deposit flag ShootCal stores on the Google event (or the legacy color marker). Personal events, tentative holds, and unpaid sessions never appear. (There is also an opt-in 'all busy events' mode for a brand-new user who hasn't started marking deposits; even then no event text is included.) 2. Each published event carries only timing. Each event in the feed includes only its identifier and start/end times and a busy status. There is no title, location, description, attendee, or organizer.
This is belt-and-suspenders: before the file is written to disk, ShootCal scans the generated text for any of a list of forbidden properties (title, description, location, attendee, organizer, contact, url, and similar). If any is found, the build fails closed and the old file is left untouched. The event identifier is also sanitized of line breaks so a malformed id can't smuggle a property line in. So even the feed that ships contains nothing but busy blocks.
How a day is colored Open / Limited / Booked
The embed renders ONE calendar from an opaque busy feed that has no per-type information, so it can't color per type. Instead it resolves a single per-day capacity equal to the MAX capacity across your bookable session types, and a day reads 'Booked' only when even the most permissive type is full. (The visible legend in the embed labels these states Available / Limited / Booked.) Concretely:
- A studio type's ceiling is its back-to-back slot count derived from its fixed hours (open, close, session length, turnover); you never hand-count slots ('Fill').
- A sunset/sunrise type's ceiling is the cascade maximum of six slots.
- A fixed-clock-time type is always 1.
Your per-day limit for a type (or the studio default when the type inherits) is clamped to that physical ceiling. The cap honors which types are actually bookable, using the same bookability rules as the booking page, so the calendar can't advertise Open or Limited for a type the request page won't book. If types exist but none are structured-bookable (for example only all-day weddings), the calendar colors booked-only (capacity 1).
The day's open times: golden-hour cascade vs studio grid
Both the slot picker and the submit path use the same slot-planning logic (the same logic runs in the native app and the web app), so the picker and the server agree exactly. By session-type mode:
- sunset / sunrise: ShootCal computes the actual sunset or sunrise time for that date at your stored latitude and longitude. It then packs sessions back from sunset (or forward from sunrise) in session-length plus break steps, routing AROUND your existing calendar events, up to six candidate slots. The first slot anchored at the sun event is flagged as prime (the 'Golden hour' tag on the page). This requires a location; with none, sun types yield no slots.
- studio: tiles your fixed open-to-close hours in session-length steps plus turnover gaps; every tile is a slot, and a tile overlapping a busy event is flagged as already booked.
- fixed: a single clock time.
- all-day / manual / empty mode: never bookable online (no timed slots).
Every candidate is checked against your real calendar. Crucially, ALL timed events block slots (a client can't book over your personal appointment), but only deposit-paid events are tagged as paid, and only those paid ranges are ever published to the public 'Already booked' chips. A personal event blocks the time but its exact hours are never shown.
The crucial distinction: public availability is capped, the manual picker is not
This is the heart of the 'public vs manual' difference. For a sunset/sunrise type, the full cascade can produce up to six candidate slots, but the PUBLIC booking page does not publish all of them. For sun types it figures out how many slots remain (your per-day limit minus what is already booked for that type that day) and publishes only that many open slots. With the default per-day limit of one, the public page offers exactly ONE golden-hour slot (the prime time), even though the cascade physically fits more.
That cap is deliberate: it makes the public page match the day-full gate and the calendar's per-day coloring. The full multi-slot cascade is the MANUAL tool: it's there for you (in the app or manual entry) to stack several sessions into one golden hour when you choose to, but the public self-booking flow won't oversell your evening. Studio and fixed types instead offer all their slots (a booked one is shown and flagged), because their per-slot availability already gates capacity.
Your per-day limit is enforced again at submit time: ShootCal counts only the accepted bookings of that type on the day (in your calendar's timezone), and a request over the limit is rejected as 'day full'. Studio 'Fill' types leave the day-count uncapped and let per-slot availability do the gating.
Request → review → accept (the default flow)
By default booking is request-mode and a public submission NEVER creates a calendar event. The submit step validates everything, then for request-mode stores a pending request with the picked start and end times. The slot is left OPEN while pending. It then best-effort emails you (the request is saved even if the email fails).
The pending request shows up in two places, both of which call the same accept/decline logic:
- The magic-link page in the notification email, with accept and decline buttons. This page is hardened against being embedded in a frame (it shows client personal information), so other sites cannot iframe it.
- The Booking Requests inbox in the web app and native app, which lists requests (pending first, soonest slot first) and offers the same accept and decline.
Accept re-validates that the slot is still free against your live calendar, then creates the confirmed Google event (with client name, email, phone, and notes in the description) and marks the request accepted with the new event's id. Decline just marks it declined: nothing was ever on the calendar, so there's nothing to unwind. A declined request can be reopened to pending later.
Instant mode, and where everything is stored
Instant mode is opt-in and resolved per session type (it reads the type's booking mode, falling back to your global setting). For security it is ALWAYS derived from the resolved type, never from a field in the client's submission, so a tampered request can't flip a request-only type into a calendar insert. In instant mode ShootCal claims the slot in its own database first (as accepted, with no event id yet) inside an atomic database transaction that re-checks the per-day limit and slot overlap, THEN creates the Google event and fills in its id. If the event creation fails or returns no id, the claim is deleted so the slot reopens.
Where data lives (server-side, scoped to your account):
- Your booking-page config: the enabled flag, the public token and any custom slug, which calendar to use, lead time, how far ahead bookings open, your per-day limit, which weekdays and which session types are bookable, the booking mode, whether to notify the client, blackout dates, an optional redirect URL, and your business name.
- Every request and instant-book: its status (pending, accepted, declined, responded, or canceled), the slot times, the client's name, email, phone, and notes, the request's secret token, the linked Google event id, decision and response timestamps, and any flexible-date preferences.
- A lightweight audit and rate-limit record per submission: which account it belongs to, the visitor's IP hashed with the app's secret key (never the raw IP), the client-supplied email (empty for demo and rate-limit-only rows), the slot start, and a timestamp. That IP hash is what enforces the per-hour-per-IP limit.
- Your synced session types and studio defaults (which override the legacy global rule settings).
- The availability feed file used by the embed.
- The optional studio logo shown atop the embed.
Anti-abuse, self-healing, and the reply tracking path
Several safeguards run on the public path:
- Honeypot: a hidden field a real person never sees. A bot that fills it gets a fake success and the submission is silently dropped (no stored request, no email, no calendar write).
- Rate limit: 5 requests per hour per IP, keyed on the visitor's IP hashed with the app's secret key (the IP comes from Cloudflare's connecting-IP header, falling back to the raw connection address). It even logs a row for the public demo page (with an empty email) so the cap applies there too.
- Demo page (the shootcal.com availability try-it flow): runs every validation for real but stores no request and sends no email, so it can't be used as a mail relay.
- Re-validation on accept/instant-book: an atomic database transaction plus an overlap check make it impossible for two requests on the same slot to both become events.
- Self-healing drift: before gating availability, ShootCal checks accepted bookings against the live Google calendar; if you deleted or moved the event in Google, the booking is marked 'canceled' so it stops blocking the slot. It fails safe (keeps blocking) if Google is briefly unreachable.
Notifications: by default you're emailed each request. If reply-tracking (Brevo plus a tracking domain) is configured, the email is sent from ShootCal with a per-request tracking Reply-To, so when you hit Reply your text is relayed to the client from your own Gmail and the request is marked answered. You can also set reply-mode to 'app', which sends no email and you handle everything in the inbox.
FAQ
Can a member of the public ever see who I'm shooting, or what the shoot is, from my availability calendar?
No. The published feed only contains each event's identifier, start and end times, and a busy status: no title, location, description, or attendees. A forbidden-property audit runs before the file is written and fails closed if any of those ever appear. And only deposit-paid days are published at all, so personal events and unpaid holds never even show as busy.
If I have a personal appointment on my calendar, will it block a client from booking over it, even though it's not published?
Yes. ALL timed events block slots, so a client can't book over your personal appointment. Only deposit-paid events are tagged 'paid', and only those paid ranges appear in the public 'Already booked' chips and the availability feed. So a personal event blocks the time without its details ever being shown publicly.
Does submitting the booking form put the event on my calendar automatically?
In the default request mode, no. The submission creates a pending request and emails you; the slot stays open. You Accept (which creates the event then, re-validating the slot is free) or Decline. Only if a session type is set to instant mode is the confirmed event created immediately, and that mode is resolved from the type on the server, never from the client's request.
Why does my public golden-hour booking page show only one time, when my session can fit several back-to-back?
The cascade can fit up to six sun-anchored sessions, but the page caps what it PUBLISHES to your per-day limit minus what's already booked that day. With the default limit of one, only the prime golden-hour slot is offered publicly. The full multi-slot cascade is the manual tool for you to stack sessions yourself; the public page is intentionally capped so it can't oversell your evening, and so it matches the day-full gate and the calendar's coloring. Raise the per-day limit on the type to publish more.
What stops two people from booking the same slot at the same instant?
Both the instant-book path and the accept path do the overlap check and the claim inside a single atomic database transaction. The database serializes them, so the second accept waits, then sees the first's now-accepted booking and bails. One slot can never become two calendar events. ShootCal's own database is treated as authoritative here because Google's free/busy read is eventually consistent and pending requests aren't on the calendar at all.
I deleted an accepted booking directly in Google Calendar. Will that slot stay blocked forever on my booking page?
No. Before availability is gated, ShootCal compares this day's accepted bookings against the live Google events. Any accepted booking whose Google event was deleted or moved off the day is self-healed to canceled so it stops blocking the slot and stops counting toward the per-day limit. This runs whenever slots are computed, on submit, and on accept. If Google is briefly unreachable it fails safe and changes nothing, keeping the current blocking.
How is the per-day capacity decided for the single embedded calendar, given it has no per-type data?
The embed colors the one calendar by the MAX capacity across your bookable session types (a day is 'open' if ANY type has room). Studio capacity is derived from its fixed-hours grid, sun types use the cascade max of six, fixed types are one; your per-day limit (or studio default) is clamped to that physical ceiling. It also respects which types are actually bookable, mirroring the booking page, so it can't show Open for a type the request page won't fulfill.
Is the public booking endpoint protected against spam and being used as a mail relay?
Yes, several ways. There's a hidden honeypot field (a bot that fills it gets a fake success and the submission is dropped). There's a 5-per-hour-per-IP rate limit keyed on the visitor's IP hashed with the app's secret key (the IP is taken from Cloudflare's connecting-IP header, falling back to the raw connection address). Notification emails never go to the client-supplied address on a request (they go to you), and no attendee is added. The shootcal.com demo availability page runs all checks but stores no request and sends no email, so it can't relay mail either.
What's the difference between the random token and the custom slug in my booking link?
Both resolve the same page. When you save a booking config you get a random token; you can also set a custom slug (a pretty handle, lowercased and sanitized, 3 to 40 characters). ShootCal resolves the slug first, then the token. A set of reserved words (book, app, admin, api, and so on) and any slug already taken by another user are rejected. The link shown to you uses the slug if set, otherwise the token.
Can the availability calendar and booking page be embedded on Squarespace or Wix?
Yes. The embed allows framing so site builders can iframe it, and it posts its height to the parent so the iframe auto-sizes. It's edge-cached (10 minutes) and refreshed when your calendar changes. The booking page also allows framing, but the photographer-only magic-link review page is hard-blocked from framing because it shows client personal information.