WP Manifestindependent plugin directory
manifest / unclassified / wordpress-calendar-sync-plugin

Anderson Appointment Booking

Let visitors book appointments with you from your WordPress site. Availability is read live from your Google Calendar and every booking is added to it.

★ 0stars

Install

Download source zip No release zip yet

This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.

Install straight from GitHub with WP-CLI:

wp plugin install https://github.com/cptnope/wordpress-calendar-sync-plugin/archive/refs/heads/main.zip
No built-in updater. New versions won't appear under Dashboard → Updates. Install new releases yourself, or let Git Updater track the repository.

Let visitors book appointments with you on your own WordPress site. Open times come live from your Google Calendar (and optionally Outlook), every booking lands in Google Calendar with a Meet link and invite, paid sessions go through Stripe, and the booking calendar looks like it was built for your theme.

Every feature is free. No add-ons, no SaaS in the middle, no build step.

Contents

Why this plugin

Built for solo professionals and small businesses who live in Google Calendar: consultants, freelancers, coaches, agencies, therapists, tutors. Compared with the usual WordPress booking plugins (full research):

This plugin Typical WordPress booking plugins (Oct 2026)
Two-way Google Calendar Free: live free/busy, events created, moves and deletions in Google mirrored back Often paid: Amelia Pro, Bookly add-on, LatePoint paid tiers, Booknetic add-on
Stripe payments Free, hosted Checkout, automatic refunds Often an add-on or higher tier
Looks like your site Reads your theme's colors, buttons, fonts and corners automatically Plugin-styled widget; custom CSS expected
Double-booking protection Atomic lock and live re-check at the moment of booking Rarely documented
Outlook / Microsoft 365 conflicts Free, recurring meetings included Varies; often paid
Where your data lives Your WordPress database and your Google account Same for plugins; SaaS tools (Calendly, Cal.com) keep it on their servers
Weight on the page About 14 KB of JavaScript and CSS (gzipped), no libraries, loaded only where the calendar appears Varies; many ship their own frameworks

Features

Area What you get
Availability Live free/busy from Google Calendar (several calendars), optional Outlook / Microsoft 365 calendars, weekly hours with breaks, days off, buffers before and after, minimum notice, booking window, daily cap
Appointment types Each with its own length, start-time spacing, hours, location, Google Meet setting, price and booking questions; visitors pick a type, then a time
Booking questions Short answer, long answer, dropdown, checkbox; required or optional; answers go to the Google event, emails, booking record and CSV
Payments Stripe Checkout per type, the slot held while paying, confirmation after payment only, automatic refunds by policy
Self-service One link in every email to reschedule (same calendar, own slot treated as free) or cancel, with a cut-off and a reschedule limit
Google Calendar Events with visitor details, Meet links, guest invites; moves and deletions made in Google Calendar flow back within the hour
Emails Confirmation (with a calendar file when Google isn't inviting), reminder, moved, cancelled with refund details, owner notices
Design Theme matching for block and classic themes, live preview inside your theme, per-block and shortcode overrides, CSS variables, container-query layout, keyboard navigation
Reliability Atomic slot lock, fails closed when a calendar can't be read, retries with backoff, idempotent Google and Stripe calls, Site Health checks, owner alerts
Admin Bookings list with type, payment and status; CSV export; problem log; "Check Google Calendar now"
Privacy Narrow OAuth scopes, secrets encrypted at rest, personal-data export and erase, automatic record clean-up

How it works

Architecture

flowchart LR
    subgraph Visitor["Visitor's browser"]
        W["Booking widget"]
        M["Manage page"]
    end

    subgraph WP["Your WordPress site"]
        REST["REST API<br/>aab/v1"]
        AV["Availability"]
        BK["Bookings"]
        PAY["Payments"]
        CRON["Hourly tasks"]
        DB[("Database")]
    end

    subgraph Ext["External services"]
        G["Google Calendar"]
        MS["Microsoft Graph"]
        S["Stripe"]
    end

    W --> REST
    M --> REST
    REST --> AV
    REST --> BK
    BK --> PAY
    BK --> DB
    CRON --> BK
    CRON --> PAY
    AV -->|free/busy| G
    AV -->|busy times| MS
    BK -->|create, move, delete| G
    PAY -->|checkout, refunds| S
    S -.->|webhook| REST
Part Role
Booking widget Type picker, month calendar, times, form; theme-matched; about 14 KB gzipped
Manage page Linked from every email: reschedule, cancel, finish or release a payment
Availability Builds candidate times from hours and types, removes busy times, buffers, caps
Bookings Atomic slot lock, live re-check, records, Google events, emails
Payments Holds the slot, Stripe Checkout, confirmation, refunds
Hourly tasks Google sync, reminders, payment sweep, retention

Booking a free appointment

sequenceDiagram
    autonumber
    actor V as Visitor
    participant W as Widget
    participant R as REST API
    participant B as Bookings
    participant G as Google Calendar
    actor O as You

    V->>W: Pick type, day, time
    W->>R: GET /availability?type=…
    R->>G: freeBusy (cached 2 min)
    G-->>R: busy ranges
    R-->>W: open start times
    V->>W: Name, email, answers
    W->>R: POST /bookings
    R->>R: Honeypot, timing, rate limit, validation
    R->>B: create()
    B->>B: Atomic lock on the start time
    B->>G: freeBusy again (live, no cache)
    alt Time still free
        B->>G: Insert event (fixed ID, Meet, invite)
        G-->>B: Event + Meet link
        B-->>V: Confirmation email with manage link
        B-->>O: New booking email
        R-->>W: Booked
    else Just taken
        R-->>W: 409, reload times
    end

Booking a paid appointment

The slot is held while the visitor pays on Stripe's hosted page. Nothing is confirmed until Stripe says it's paid, and every confirmation path re-reads the payment from Stripe.

sequenceDiagram
    autonumber
    actor V as Visitor
    participant W as Widget
    participant R as REST API
    participant P as Payments
    participant S as Stripe
    participant G as Google Calendar

    V->>W: Submit form for a paid type
    W->>R: POST /bookings
    R->>P: Lock, live re-check, then hold the slot (about 35 min)
    P->>S: Create Checkout Session (idempotency key)
    S-->>P: Session URL
    R-->>W: checkout URL
    W->>S: Redirect to Stripe Checkout
    V->>S: Pays
    par Webhook
        S->>R: checkout.session.completed (signed)
        R->>P: Verify signature, amount, currency
    and Visitor returns
        S-->>V: Redirect to manage page
        V->>R: Manage page asks Stripe for the session
    end
    P->>G: Insert event (safe if both arrive)
    P-->>V: Confirmation email
    Note over P,G: If Google is down after payment, the slot stays held,<br/>you're emailed, and the hourly task retries.

Booking lifecycle

stateDiagram-v2
    [*] --> pending: Free type booked
    [*] --> awaiting_payment: Paid type booked
    pending --> confirmed: Google event created
    awaiting_payment --> confirmed: Paid and Google event created
    awaiting_payment --> expired: Not paid within the hold
    awaiting_payment --> cancelled: Visitor backs out of checkout
    confirmed --> confirmed: Rescheduled (visitor or Google Calendar)
    confirmed --> cancelled: Visitor, you, or deleted in Google Calendar
    cancelled --> [*]
    expired --> [*]

    note right of awaiting_payment
        Holds the slot while unpaid.
        Once paid it holds until confirmed.
    end note
    note right of cancelled
        Paid bookings are refunded:
        always when you cancel,
        before the cut-off when the visitor cancels.
    end note
Status Holds the slot Visitor can change it Shown in bookings list as
pending Briefly, while the Google event is created No Not confirmed
awaiting_payment Yes, until the hold ends (forever once paid) Pay or release from the manage page Awaiting payment / Paid, adding to calendar
confirmed Yes, plus its buffers on both sides Reschedule or cancel until the cut-off Confirmed / Past
cancelled No No Cancelled / Deleted in Google Calendar
expired No No Expired (not paid)

How open times are calculated

flowchart TD
    A["Date range requested<br/>(one month at a time)"] --> B["Clamp to booking window<br/>today … today + N days"]
    B --> C{"Day off?"}
    C -->|yes| X["No times that day"]
    C -->|no| D["Type's hours or your usual hours<br/>e.g. 09:00-12:00, 13:00-17:00"]
    D --> E["Candidate starts every N minutes<br/>that fit the type's length<br/>(DST-safe, site timezone)"]
    E --> F["Drop starts sooner than minimum notice"]
    F --> G["Busy ranges"]
    G --> G1["Google free/busy<br/>booking calendar + conflict calendars"]
    G --> G2["Outlook calendarView<br/>Busy, Tentative, Out of office"]
    G --> G3["Bookings made here<br/>widened by their buffers<br/>(payment holds included)"]
    G1 & G2 & G3 --> H{"Candidate + buffers<br/>overlaps anything?"}
    H -->|yes| Y["Not offered"]
    H -->|no| I{"Daily cap reached?"}
    I -->|yes| Y
    I -->|no| J["Offered"]
    G1 -. "can't be read" .-> Z["Fail closed:<br/>no times offered"]
    G2 -. "can't be read" .-> Z

Rescheduling

sequenceDiagram
    autonumber
    actor V as Visitor
    participant P as Manage page
    participant R as REST API
    participant B as Bookings
    participant G as Google Calendar

    V->>P: Opens link from any email (token)
    P->>V: Appointment, Reschedule, Cancel
    V->>P: Reschedule
    P->>R: GET /availability?booking=ID&token=…
    Note right of R: The booking's own slot counts as free,<br/>so it can move 30 minutes later.
    R-->>P: Open times for its type
    V->>P: Picks a new time, confirms
    P->>R: POST /bookings/ID/reschedule
    R->>B: Token, cut-off, limit, atomic lock, live re-check
    B->>G: PATCH the same event (same Meet link, guests updated)
    B-->>V: "Moved" email
    B-->>V: Same manage link keeps working

Hourly background tasks

flowchart LR
    T(["WP-Cron, hourly"]) --> S1["Sync with Google Calendar"]
    T --> S2["Reminders"]
    T --> S3["Payments sweep"]
    T --> S4["Retention"]
    S1 --> S1a["Event deleted → cancel booking,<br/>refund if paid, email visitor"]
    S1 --> S1b["Event moved → update booking,<br/>email visitor"]
    S2 --> S2a["Email visitors N hours before,<br/>once, skipping last-minute bookings"]
    S3 --> S3a["Paid but unconfirmed → retry Google"]
    S3 --> S3b["Unpaid and past hold → release"]
    S3 --> S3c["Missed webhook → ask Stripe"]
    S4 --> S4a["Delete records older than N days<br/>(if enabled)"]

Requirements

Minimum Notes
WordPress 6.2
PHP 7.4 Tested in CI on 7.4 to 8.4; OpenSSL recommended (encrypts stored secrets)
Google account Any Personal Gmail or Google Workspace
Stripe account Optional Only for paid appointment types
Microsoft account Optional Work, school or personal, only for Outlook conflict calendars
Site timezone A city Fixed UTC offsets ignore daylight saving time; Site Health warns

Setup

Install the plugin (upload the zip under Plugins → Add New) and open Appointments → Settings. Each connection has step-by-step instructions on that screen with the exact URLs to copy.

1. Google Calendar (required)

Step Where What to do
1 Google Cloud Console Create or pick a project, enable the Google Calendar API
2 Google Auth Platform Choose External, fill in app name and email
3 Audience Publish app so it's In production (apps left in Testing lose access after 7 days)
4 Clients Create a Web application client with redirect URI https://your-site/wp-admin/admin-post.php?action=aab_oauth
5 Appointments → Settings Paste client ID and secret, save, Connect Google Calendar
6 Appointments → Settings Choose the booking calendar and conflict calendars, set your hours

2. Appointment types

Open Appointments → Appointment types and add what visitors can book (see Appointment types). Upgrading from 1.2 or earlier creates the first type from your old settings.

3. Stripe (optional, for paid types)

Step Where What to do
1 Stripe → Developers → API keys Copy the Secret key (start with sk_test_…)
2 Stripe → Developers → Webhooks Add endpoint https://your-site/wp-json/aab/v1/stripe with events checkout.session.completed, checkout.session.expired, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed
3 Same page Copy the Signing secret (whsec_…)
4 Appointments → Settings → Payments Paste both, set currency and refund cut-off
5 Appointment types Give a type a price

Without the webhook, paid bookings still confirm when the visitor returns from Stripe or within the hour.

4. Outlook / Microsoft 365 (optional)

Step Where What to do
1 Microsoft Entra → App registrations New registration, accounts in any organizational directory and personal Microsoft accounts
2 Redirect URI Platform Web: https://your-site/wp-admin/admin-post.php?action=aab_ms_oauth
3 API permissions Microsoft Graph, delegated: Calendars.Read, User.Read, offline_access
4 Certificates & secrets New client secret; note its expiry (24 months at most)
5 Appointments → Settings → Outlook Paste the Application (client) ID and secret value, Connect Outlook, tick calendars

5. Put the calendar on a page

Method Example
Block Appointment Booking block; sidebar picks one type or all, plus style overrides
Shortcode, all types [anderson_booking heading="Book a call"]
Shortcode, one type [anderson_booking type="consult"]

Keeping secrets out of the database

Any of these in wp-config.php takes precedence over the settings screen:

define( 'AAB_GOOGLE_CLIENT_ID', '…apps.googleusercontent.com' );
define( 'AAB_GOOGLE_CLIENT_SECRET', '…' );
define( 'AAB_STRIPE_SECRET_KEY', 'sk_live_…' );
define( 'AAB_STRIPE_WEBHOOK_SECRET', 'whsec_…' );
define( 'AAB_MS_CLIENT_ID', '…' );
define( 'AAB_MS_CLIENT_SECRET', '…' );
define( 'AAB_ENCRYPTION_KEY', 'a long random string' ); // optional: key for secrets stored in the DB

Appointment types

erDiagram
    APPOINTMENT_TYPE ||--o{ QUESTION : asks
    APPOINTMENT_TYPE ||--o{ BOOKING : "booked as"
    BOOKING ||--o| GOOGLE_EVENT : "creates"
    BOOKING ||--o| STRIPE_SESSION : "paid through"
    BOOKING ||--o{ ANSWER : stores

    APPOINTMENT_TYPE {
        string id "used in type=..."
        string name
        int duration "minutes"
        int interval "start times every"
        bool custom_hours
        string location
        string meet "default, on, off"
        int price "minor units, 0 = free"
        bool active
    }
    QUESTION {
        string id
        string label
        string kind "text, textarea, select, checkbox"
        bool required
    }
    BOOKING {
        int id "private post"
        int start "unix"
        int end
        string status
        string email
        string event_id
        int amount
        int paid_at
        int reschedules
    }
    ANSWER {
        string label
        string value
    }
Per type Shared by all types
Name, description Buffers before and after
Length, start-time spacing Minimum notice, booking window
Own weekly hours (or your usual ones) Days off
Location, Google Meet on/off Daily cap, per-person cap
Price Change and refund cut-offs
Questions, notes box Calendars checked for conflicts
Visible / hidden, order Emails, styling

Types share buffers and limits because one calendar holds one appointment at a time: a consult blocks intro calls in the same hour. Deleting a type keeps its bookings, which can still be rescheduled.

Settings reference

Setting Default Notes
Usual hours Mon–Fri 09:00-17:00 24-hour ranges, commas for breaks
Free time before / after 0 / 15 min Applied around new bookings and kept around existing ones
Minimum notice 12 h
Book up to 30 days ahead
Most bookings per day 0 (no limit) Counts payment holds
Upcoming bookings per person 2 By email address
Changes allowed until 0 h before Reschedule and cancel cut-off for visitors
Reschedules per booking 0 (no limit)
Days off none 2026-12-24 or 2026-12-24 to 2027-01-02, # notes allowed
Calendar event title Appointment with {name} {name}, {type}, {site}
Location empty Types can override
Google Meet link on Types can override
Invite visitor as guest on Off: a calendar file is attached to emails instead
Phone number Don't ask Optional or required
Reminder email 24 h before 0 turns it off
Consent checkbox empty Text shown next to a required checkbox
Currency USD Payments
Refund visitor cancellations until 24 h before You cancelling always refunds
Keep booking records 0 (forever) Days after the appointment
Delete data on uninstall off Google events are never touched

Styling

The calendar always uses the surrounding page's font and text color. Appointments → Settings → Appearance adds a live preview rendered inside your actual theme.

flowchart LR
    A["Plugin defaults<br/>(zero specificity)"] --> B["Theme tokens<br/>theme.json or probed from page"]
    B --> C["Appearance settings"]
    C --> D["Block / shortcode overrides"]
    D --> E["Custom CSS"]
    E --> F["Your theme's .aab rules"]
    F --> G(["What visitors see"])
Option "Match my theme" does Or
Accent color Block themes: the theme's button color from theme.json (as a --wp--preset--color--* variable, so style variations follow). Classic themes: reads the real button or link color from the page Any color, theme palette as swatches
Booking button and fields The theme's own button and input styles The calendar's own style in the accent color
Corners The theme's button radius Square, slightly rounded, rounded, very rounded
Background None (page shows through) The theme's page color, or any color
Spacing Compact, comfortable, roomy
Headings The theme's heading font (block themes from theme.json, classic themes read from the page) Same font as surrounding text
Custom CSS Loaded only on pages with the calendar

Shortcode attributes

Attribute Values Example
heading Text heading="Book a call"
type An appointment type ID type="consult"
accent Hex color accent="#7c3aed"
background none, theme, hex color background="#faf5ff"
buttons theme, plugin buttons="plugin"
radius auto, square, soft, rounded, round radius="round"
density compact, comfortable, roomy density="compact"
headings theme, inherit headings="inherit"

CSS variables

Declared at zero specificity, so a plain .aab { … } rule in your theme wins:

Variable Controls
--aab-accent / --aab-on-accent Selected day, bars, links, plugin-style button / text on it
--aab-radius Outer corners (controls derive smaller radii)
--aab-space Spacing multiplier (0.75 to 1.3)
--aab-surface Calendar box background
--aab-text, --aab-muted Text and secondary text
--aab-line, --aab-soft, --aab-tint Borders, open-day background, hover tint
--aab-error Errors
--aab-heading-font Month and panel titles
--aab-max-width Widget width (none when the block is wide or full)

Parts: .aab__types, .aab__type, .aab__type-price, .aab__body, .aab__cal, .aab__month-label, .aab__nav, .aab__day (.is-open, .is-selected), .aab__bar, .aab__panel, .aab__time, .aab__form, .aab__field, .aab__input, .aab__submit, .aab__done, .aab__tz. Calendar buttons are protected from theme button rules with .aab:not(#aab-x) …; use the same prefix, or the variables, to restyle them.

Emails

Email To When Includes
Appointment booked Visitor Confirmed (after payment for paid types) Type, time with timezone, Meet link or location, manage link, calendar file if not invited by Google
New booking You Confirmed Contact details, answers, notes, Google Calendar link
Reminder Visitor N hours before (once) Details and manage link
Appointment moved Visitor (and you if the visitor moved it) Rescheduled, or moved in Google Calendar Old and new time, manage link
Appointment cancelled Visitor (and you if the visitor cancelled) Cancelled by anyone Refund outcome for paid bookings
Booking offline You Google or Outlook access breaks (once per outage) Reconnect link
Paid booking waiting You Paid but Google unreachable Retried hourly
Refund needed You Automatic refund failed Amount and visitor, to refund in Stripe

Google Calendar also emails invited guests its own invites and updates.

Developers

REST API (/wp-json/aab/v1)

Method and route Purpose Auth
GET /availability?from&to&type Open start times per day for a type (max six weeks) Public, rate-limited
GET /availability?from&to&booking&token Same, for rescheduling a booking (its own slot counts as free) Emailed token
POST /bookings Book (start, name, email, type, answers, phone, notes, consent); returns the booking or a Stripe checkout URL Public, honeypot, timing, rate-limited
POST /bookings/{id}/reschedule Move a booking (start, token) Emailed token
POST /stripe Stripe webhook Stripe signature

Hooks

Hook Type Arguments
aab_booking_confirmed action $booking
aab_booking_rescheduled action $booking, $old_start
aab_booking_cancelled action $booking, $by (visitor, admin, calendar)
aab_client_ip filter $ip, $remote_addr
aab_enqueue_styles filter $load (return false to style everything yourself)
aab_style_vars filter $vars, $overrides
aab_theme_tokens filter $tokens

File map

Piece File
Bootstrap anderson-appointment-booking.php
Google OAuth and Calendar v3 includes/class-aab-google.php
Microsoft Graph (Outlook) includes/class-aab-microsoft.php
Stripe client includes/class-aab-stripe.php
Paid booking flow includes/class-aab-payments.php
Slot engine includes/class-aab-availability.php
Bookings, locks, emails, calendar files includes/class-aab-bookings.php
Appointment types and answer validation includes/class-aab-types.php
REST API includes/class-aab-rest.php
Shortcode, block, manage page, preview includes/class-aab-frontend.php
Theme detection and style resolution includes/class-aab-style.php
Hourly tasks and Site Health includes/class-aab-maintenance.php
Admin screens includes/class-aab-admin.php, includes/class-aab-admin-types.php, includes/views/
Widget assets/js/booking.js, assets/css/booking.css
Block blocks/booking/
Translations template languages/anderson-appointment-booking.pot

Development

No build step: edit and reload. CI (GitHub Actions) checks PHP syntax on 7.4 to 8.4 and JavaScript syntax on every push. Release zips come from git archive, which leaves out docs/, .github/ and this README (see .gitattributes).

Security and privacy

Concern How it's handled
Google, Stripe and Microsoft secrets AES-256-GCM encrypted at rest (key from WordPress salts or AAB_ENCRYPTION_KEY), or kept in wp-config.php
Google scopes calendar.events, calendar.freebusy, calendar.calendarlist.readonly only
Microsoft scopes Calendars.Read, User.Read, offline_access (read-only)
Event details from conflict calendars Never read: free/busy (Google) and start, end, show-as (Outlook) only
Card data Never touches your site: Stripe-hosted Checkout
Payment confirmation Webhook signature (HMAC-SHA256, 5-minute tolerance) and amount/currency checked; return URLs re-check with Stripe
Double charges or refunds Stripe idempotency keys on every write
Visitor links 32-character random token, stored hashed; cancel needs a POST so mail scanners can't trigger it
Spam Honeypot, minimum form time, per-IP rate limits that see the real IP behind private proxies only
OAuth state checked on Google and Microsoft sign-in
Admin actions Capability checks and nonces
Output Escaped; data rendered with textContent in the widget
CSV export Formula injection neutralized
Personal data WordPress export and erase tools; optional automatic deletion after N days

Running it in production

Topic Notes
Proxies and caches (Cloudways/Varnish, nginx, Cloudflare) Real visitor IP read from CF-Connecting-IP, X-Real-IP, X-Forwarded-For only when PHP is reached from a private or loopback address. Page caching is fine: times load through the REST API with Cache-Control: no-store
WP-Cron disabled Add a server cron job that requests wp-cron.php every few minutes; Site Health warns if the hourly task stops
Google access breaks Booking stops safely, one owner email, wp-admin notice until reconnected
Outlook access breaks Same: times aren't offered without it; Site Health turns critical
Transient failures Google, Stripe and Microsoft calls retry with backoff; Google events use fixed IDs so retries never duplicate
Concurrency Six simultaneous requests for one slot: exactly one booking, one Google event (tested)
All-day events Free by default in Google Calendar and don't block; mark them Busy or use Days off
Stripe test vs live Test keys show "test mode" on the settings screen; switch keys when you go live and re-create the webhook in live mode
Logs Recent Google, Stripe and Microsoft errors on the settings page

Troubleshooting

Symptom Likely cause Fix
"Online booking is unavailable" Google not connected or access expired Reconnect under Appointments → Settings
Booking stops working every 7 days Google OAuth app left in Testing Publish the app to In production, reconnect
No times shown at all Outlook or Google can't be read (fails closed), or every day is outside hours, notice or window Check the problem log and Site Health
Vacation days still bookable All-day events are Free in Google Calendar Mark them Busy, or add to Days off
Paid type doesn't appear Stripe keys missing Set up Payments; the types screen warns about this
Paid booking stuck on "Paid, adding to calendar" Google unreachable after payment Nothing to do: retried hourly, you were emailed
Visitor paid but wasn't confirmed instantly Webhook not set up, or async payment method Confirms on return or within the hour
Times off by an hour twice a year Site timezone is a UTC offset Settings → General → choose a city
Reminders not sent WP-Cron not running Add a server cron job; Site Health tells you
"Refund needed" email Automatic refund failed Refund in the Stripe dashboard

Research and roadmap

License

MIT © Jeremy Anderson