Live · status OK
Documentation · OW Agenda v1.3.1

OW Agenda
Documentation

The WordPress booking plugin that never lets anyone tie up your slots for nothing.

v1.3.1GPL-2.0-or-laterDocumentation

OW Agenda — Documentation

Online appointment booking and the everyday agenda, inside WordPress, with no subscription. Author: OptionWeb — Julien Daniel Plugin home: https://optionweb.dev/en/addons/ow-agenda/ License: GPL-2.0-or-later Version covered by this document: 1.3.1


Table of contents

  1. Overview
  2. Installation
  3. Quick start
  4. The booking journey
  5. Email double verification
  6. Approving requests
  7. The Agenda page
  8. The customer directory
  9. Session invitations
  10. Emails and calendar file (ICS)
  11. Reminders and the scheduled task
  12. Anti-spam and rate limits
  13. GDPR
  14. Shortcodes
  15. REST API
  16. Settings reference
  17. Stored data
  18. Internationalization
  19. Uninstalling
  20. Troubleshooting
  21. FAQ
  22. Changelog

Overview

OW Agenda adds a complete appointment system to WordPress: a public page where the visitor picks a day and then a time, and a login-protected "Agenda" page, designed to be used from a phone, where you run your day.

Everything stays inside the site. No external service, no third-party API, no subscription: slots are computed by your server, appointments live in your MySQL tables, emails go out through wp_mail().

What ships with it (all free, GPL-2):

  • Online booking: choice of day, choice of slot, short form, GDPR consent checkbox
  • Email double opt-in: a request only becomes real once a verification link has been clicked — bots never reach the agenda
  • Manual approval (recommended) or automatic confirmation, with Confirm / Decline buttons right inside the notification email (HMAC-signed links, no login required)
  • Mobile-first "Agenda" page: today's view, pending requests, phone appointments typed in by hand, blocked slots, rescheduling, done / no-show statuses
  • Customer directory: search, create, edit, delete; filled in automatically by confirmed appointments
  • Session invitations: a private link lets an existing customer choose the date of their session, on a service and a duration you set
  • Carefully built HTML emails with an ICS attachment (one gesture to add the appointment to a phone calendar), an automatic reminder the day before, online cancellation by the customer
  • Automatic anonymisation of past appointments, purge of requests that were never verified
  • A complete REST API under the owag/v1 namespace

What it deliberately does not do:

  • No online payment: OW Agenda books the appointment, it does not collect a deposit (use WooCommerce if that is what you need)
  • A single agenda: no multi-practitioner or multi-resource management; the plugin describes one schedule, the site's own
  • No two-way synchronisation with Google Calendar or Outlook: the customer's calendar is updated through an ICS file (one way only), your personal unavailability is entered as blocked slots
  • No service catalogue on the visitor side: public booking covers a single service (configurable label and duration); other services go through session invitations
  • No recurring appointments, no video conferencing, no waiting room

That list is an architectural choice, not a hidden roadmap: a simple agenda that never gets a slot wrong beats a monster nobody dares to touch.


Installation

From the .zip

  1. Download ow-agenda-1.3.1.zip from https://optionweb.dev/en/addons/ow-agenda/
  2. Plugins → Add New → Upload Plugin
  3. Pick the file, click Install Now, then Activate

Over FTP

Unzip the archive and drop the ow-agenda folder into /wp-content/plugins/, then activate the plugin from Plugins.

Requirements

  • WordPress 6.3 or later
  • PHP 7.4 or later (8.1+ recommended)
  • MySQL 5.7+ / MariaDB 10.2+ — the GET_LOCK() / RELEASE_LOCK() functions are used to serialise concurrent bookings
  • Working email delivery. This is the plugin's only real dependency: the whole journey (verification, confirmation, reminder, cancellation) goes through email. On shared hosting, install an SMTP plugin before you put the agenda live.

What gets installed

On activation, OW Agenda creates three tables:

TableContents
{prefix}_owag_appointmentsEvery event in the agenda: web consultations, sessions created from an invitation, appointments typed in by hand, blocked slots
{prefix}_owag_invitesThe private "choose the date of your session" links
{prefix}_owag_customersThe customer directory

…and, the first time only, two published pages:

  • "Book an appointment" — contains the [ow_agenda] shortcode
  • "Agenda" — contains the [ow_agenda_admin] shortcode

Their IDs are remembered in the owag_page_booking and owag_page_admin options; the owag_pages_created flag makes sure that deactivating and reactivating does not create duplicates. You can rename these pages, move them into a menu, change their permalink: the plugin follows them by ID.

Activation also adds:

  • the owag_manage capability to the administrator role — that capability, and not manage_options, is what opens the agenda and the admin REST routes;
  • the default settings in the owag_settings option;
  • the hourly scheduled task owag_hourly_event (first run 5 minutes after activation).

An HMAC secret (owag_admin_secret, 64 characters, not autoloaded) is generated on the first admin notification: it is what signs the Confirm / Decline buttons in the emails.

Giving access to someone else

The Agenda page is visible to any logged-in user holding owag_manage. To open the agenda to an assistant without handing over the keys to the site:

// Run once (mu-plugin, or a role management plugin).
$role = get_role( 'editor' );
if ( $role ) { $role->add_cap( 'owag_manage' ); }

Quick start

After activation, go to OW Agenda (main admin menu). The settings screen fits on one page; five minutes is enough.

1. Opening hours

For each day of the week, enter a range (09:00 → 18:00) and, if you close over lunch, a second range (14:00 → 18:30). A day left empty is a closed day. By default: Monday to Friday, 09:00–18:00; Saturday and Sunday closed.

Hours are interpreted in the site's time zone (Settings → General → Timezone). Check it before you open booking: every date is stored in local time.

2. The service that can be booked online

  • Consultation label — what the visitor sees as the title of the widget ("Consultation", "First appointment", "Assessment"…)
  • Consultation length — 60 minutes by default. That is the duration blocked out in the agenda.
  • Slot granularity — the gap between two offered times (30 minutes by default). With a 60-minute length and a 30-minute granularity, you offer 9:00, 9:30, 10:00… and the first appointment booked at 9:30 removes 9:00 and 10:00 from the list.
  • Buffer between two appointments — breathing minutes added on either side of every candidate slot (0 by default).

3. Lead times

  • Minimum booking notice — 24 h by default: a visitor cannot book for an hour from now.
  • Booking horizon — 60 days by default: beyond that, nothing is offered.
  • Online cancellation by the customer — up to 24 h before the appointment; past that point, the cancellation page invites them to call.

4. Approval and emails

  • Request confirmation: manual approval (recommended) or automatic confirmation.
  • Email double verification: leave it ticked. It is the plugin's best anti-bot protection.
  • Admin notifications sent to: your address. The site's admin email by default.

Save, then open the "Book an appointment" page: the widget shows the next 14 days and your free slots. You are live.

5. The agenda on your phone

Open the "Agenda" page on your phone, log in, then use "Add to Home Screen". The plugin publishes a web app manifest: the page then opens full screen, with no browser bar, like an app.


The booking journey

On the visitor's side, the booking page is a self-contained component (framework-free JavaScript, under 10 KB) that talks to the public owag/v1/public/* endpoints.

Step 1 — the day

On load, the widget asks for availability over 14 days starting today and shows a strip of clickable days. A day with no free slot is greyed out and not clickable. The "See the next dates" button loads the following 14 days, as many times as needed up to the configured horizon.

The widget's dates are built in the browser's local time, never in UTC. This was fixed in 1.3.0: before that, a visitor east of UTC opening the page shortly after midnight saw the list start the previous day and lost a day of availability.

Step 2 — the time

Clicking a day calls /public/slots and shows the free start times. A slot is offered when all of these conditions hold:

  1. it falls inside an opening range for that day;
  2. the whole appointment (start + duration) fits before closing time;
  3. it starts after now + minimum notice;
  4. the day is inside the booking horizon;
  5. it overlaps no active event, buffer included — and "active event" covers confirmed appointments, pending requests, blocked slots, phone appointments typed in by hand, and requests not yet verified that are still holding their slot.

This is the important point: anything that occupies the agenda closes the slot online. An appointment taken over the phone and noted in the agenda disappears immediately from the slots offered on the site.

Step 3 — the form

Four fields, three of them required: name (2 characters minimum), phone (8 digits minimum), email (validated), free-text message. A GDPR consent checkbox, unticked by default, is mandatory.

The form also carries two fields the visitor never sees — a honeypot and a page-open timestamp — described in the Anti-spam section.

Step 4 — the response

Depending on your configuration, three outcomes:

ConfigurationStatus createdMessage shown
Email double verification on (default)unverified"One last step: an email has just been sent to you…"
Verification off + manual approvalpending"Your request has been received!"
Verification off + automatic confirmationconfirmed"Your appointment is confirmed!"

If the slot was taken between the list being displayed and the form being submitted, the visitor gets an explicit message (owag_slot_taken, HTTP 409) and the list of days reloads automatically. Two visitors cannot get the same slot: the critical section is serialised by a named MySQL lock, specific to your database and your table prefix, which holds even on shared hosting.


Email double verification

This is the feature that separates OW Agenda from a contact form in disguise.

When Email double verification is on (the email_verify setting, on by default):

  1. The request is recorded with the unverified status. The administrator is not notified. The appointment does not show up as a request to handle.
  2. The visitor receives a "One last step…" email containing a verification button.
  3. The slot is held for 45 minutes. Past that point without verification, it becomes available to everyone again — so a bot cannot freeze your day.
  4. Clicking the button opens a standalone page (?owag_verify=<uid>) showing the appointment summary and an "I confirm my request" button. Verification only fires on the click: antivirus software and link scanners that visit email URLs confirm nothing.
  5. The request then becomes pending (manual approval) or confirmed (automatic confirmation), the customer receives the matching email, and only then does the admin notification go out.
  6. Requests that are never verified are deleted after 48 h by the hourly task. They leave no trace and no personal data behind.

If the slot was taken by someone else between the email being sent and the click (the 45-minute window having passed), verification returns a clear error and the request is cancelled — never a ghost appointment on a slot that is already taken.

What this means in practice: an invalid email address produces no appointment at all. You no longer waste time calling back bogus numbers. The price is one extra click for the customer; experience shows the drop-off rate is far below the rate of fake appointments it prevents.

Bookings that come from a session invitation skip this step: the customer has already proved they read their inbox by clicking the private link they received.


Approving requests

Manual approval (recommended)

The default setting: auto_confirm = 0. A verified request arrives with the pending status. Nothing is promised to the customer until you have acted — but the slot is already blocked, nobody can take it out from under you.

You receive an email titled "To confirm: " containing the full summary (service, date, name, phone, email, the customer's message) and two buttons:

  • Confirm the appointment — cyan
  • Decline — red outline

The buttons in the email: HMAC-signed links

These buttons require no login. They point at your site with three parameters: owag_admin_action (confirm or refuse), uid (the appointment's random identifier) and sig, an HMAC-SHA256 signature of uid|action computed with the site's private secret (owag_admin_secret).

  • The signature is verified before a single detail is displayed: a tampered link shows nothing but "Invalid link".
  • The comparison uses hash_equals() — no information leak through response timing.
  • The page shows a summary and a button; the action only runs on the click. A link scanner therefore confirms nothing on your behalf.
  • A request that has already been handled returns "Already handled (status: …)" rather than an error.

Confirming automatically sends the confirmation email to the customer, with the calendar file. Declining sends the cancellation email. Either way, the agenda is up to date before you have put your phone away.

These links stay valid as long as the appointment is pending. If you delete the owag_admin_secret option (a partial database restore, manual cleanup), every link already sent stops working: a new secret is generated and the old signatures no longer match. Use the Agenda page in that case.

Automatic confirmation

The auto_confirm = 1 setting. A free slot is confirmed immediately: the customer receives their confirmation with the calendar file, you receive a "New appointment" notification. Keep this for businesses where every open slot is an acceptable slot.

The two settings combine with double verification: even under automatic confirmation, the request goes through unverified first if email_verify is on.


The Agenda page

The "Agenda" page ([ow_agenda_admin]) is the everyday tool. It is designed for the phone first: large buttons, a single column, no table to scroll sideways — and it obviously works just as well on a desktop screen.

It is protected: a logged-out visitor sees a login link, a logged-in user without the owag_manage capability sees "Access is restricted to the team". The page is noindex,nofollow: it will never turn up in Google.

Agenda tab — today's view

The current day, event by event, with the time, the duration, the label, the status and the origin (site, invitation, manual entry). Navigation from one day to the next, plus a Today button. On every appointment:

  • Details — the customer's contact details, the message left when booking, and an internal notes block (never visible to the customer) saved without sending a single email
  • Confirm ✓ — for a request still pending; the confirmation email goes out
  • Done / No-show — end-of-appointment statuses
  • Reschedule — new date, new time, duration changed if needed. The plugin asks whether the customer should be told by email; if so, they receive an updated confirmation and a calendar file that fixes the event already sitting on their phone
  • Cancel the appointment — again with the choice of whether to tell the customer
  • Delete the block — for a blocked slot

When an action creates an overlap (moving an appointment onto an occupied slot), the plugin warns you and asks for confirmation: you can force it. It is your schedule, you know what you are doing — the guard rail is only binding for visitors.

Adding an appointment or a block

The "Add an appointment" button opens a single form that serves two purposes:

  • Appointment — the customer called you. Name, phone, email (optional), service, date, time, duration, internal notes. If you fill in an email address and tick the option, the customer receives the confirmation with their calendar file, exactly as if they had booked online. The appointment also feeds the customer directory.
  • Block (time off, break…) — a reason, a date, a time, a duration. No email, no customer record, but the slot disappears from online booking immediately. That is how you put in a week off, a training day, or the lunch slot you do not want booked.

Pending tab

The list of verified requests waiting for your decision, across all days (appointments already in the past are excluded). Two buttons per row: Confirm and Decline. It is the first screen to open in the morning.

Tip: the agenda as an app

On the Agenda page, the plugin declares a web app manifest (?owag_manifest=1) with the site name, the site icon and the theme colour. On Android and iOS alike, the browser menu then offers "Add to Home Screen": you get an icon, a full-screen launch, and never have to retype the URL again.


The customer directory

The third tab of the Agenda page is a light directory, deliberately minimal: name, phone, email, free-text notes.

  • It fills itself in. As soon as an appointment is confirmed (verified online booking, session from an invitation, manual entry), the customer is matched to an existing record by email, then by phone. If they do not exist, the record is created; if they do, only the empty fields are completed — the plugin never overwrites what you typed in by hand.
  • Search on name, phone or email (200 records shown at most).
  • Manual creation and editing, with a notes field for history, preferences, and precautions to take.
  • Two shortcuts per record: New appointment (pre-fills the manual entry form) and Invite for a session (pre-fills the invitation).
  • Deleting a record: past appointments stay in the agenda, only the directory record disappears.

This directory is not a CRM and does not pretend to be one. It answers one question: "Who is the two o'clock again?"


Session invitations

The use case: an existing customer is entitled to a session — a follow-up, a touch-up, a service sold elsewhere — and you want them to choose their date without going through public booking, on a service and a duration you set.

From the Invitations tab:

  1. Enter the customer's name and the title of the session (both required), its duration (15 to 720 minutes) and, if you wish, their email and phone.
  2. Choose how long the link stays valid: 60 days by default, from 1 to 365.
  3. Optionally tick "Send the link to the customer by email" — the customer receives a "Choose the date of your session" email with the title, the duration, the expiry date and a button.
  4. Otherwise, copy the link and send it by SMS, WhatsApp, or through the phone's native share button.

The link points at your booking page with a private token (?owag_invite=<token>, 32 hexadecimal characters drawn from a cryptographic generator). The widget then shows the title and duration of the session, not those of the standard consultation. The customer fills in the same short form.

The rules that govern an invitation link:

  • Single use. Once the session is booked, the link answers "This link has already been used" (HTTP 410). Two simultaneous openings with the same token cannot create two sessions: the token is consumed under the same lock as the slot booking.
  • Dated. Past its expiry, the link invites the customer to get back in touch (HTTP 410).
  • No double opt-in. The customer already received this link through a channel you control: the session is created directly as confirmed, with a confirmation and a calendar file.
  • Recycled on cancellation. If the session is cancelled (by you or by the customer), the invitation is released: the link becomes active again and the invitation reappears in the list of open invitations. This was fixed in 1.3.0 — before that, a cancellation killed the link for good and you had to issue a new one.
  • Revocable. Deleting an invitation from the list disables the link immediately.

Emails and calendar file (ICS)

Transactional emails

Every message is HTML, on a table-based layout compatible with Gmail and Outlook: a white card on a light background, the site name in the header, a summary box with a cyan rule, pill buttons. They are sent through wp_mail(), so they respect your SMTP configuration and the usual WordPress filters.

EmailRecipientTrigger
Verify your requestCustomerWeb booking when double verification is on
Request receivedCustomerRequest verified, awaiting your decision
Appointment confirmedCustomerConfirmation (manual, automatic, admin entry, reschedule) — with ICS attachment
Appointment cancelledCustomerCancellation by the customer, decline or cancellation by the admin — with cancellation ICS attachment
ReminderCustomerAutomatically, between 12 and 36 h before the appointment
Choose the date of your sessionCustomerAn invitation is created with email sending enabled
To confirm: …AdminNew verified request pending — with the Confirm / Decline buttons
New appointment: …AdminNew confirmed appointment (automatic confirmation, invitation)
Customer cancellation: …AdminThe customer cancelled from their own email

The admin notification address is the notify_email setting (the site's admin email by default). Customer emails only go out if the address is valid, and never for a blocked slot.

Confirmation and cancellation emails remind the customer that they can cancel online, with the notice period you have actually configured.

The ICS attachment

The .ics file attached to confirmations is what turns an email into an appointment genuinely present on the customer's phone: one gesture on the attachment and the event is in their calendar, with its own reminder.

The file complies with RFC 5545 and contains what calendar clients (Google, Outlook, Apple) need in order to handle updates correctly:

  • METHOD:REQUEST on creation and on every schedule revision, METHOD:CANCEL on cancellation;
  • a stable UID derived from the appointment identifier and the site domain — that is what lets a revision replace the existing event instead of creating a second one;
  • an increasing SEQUENCE derived from the last-modified date, reduced to an absolute instant: the switch to winter time cannot push the revision number backwards (and cancellation adds 1, to stay above a revision that happened in the same second);
  • ORGANIZER (your notification address) and ATTENDEE (the customer);
  • DTSTART / DTEND in UTC, STATUS:CONFIRMED or STATUS:CANCELLED;
  • line folding at 75 bytes, never cutting a UTF-8 character in half.

Rescheduling a confirmed appointment therefore sends a new file that fixes the event already on the customer's device; a cancellation sends a file that removes it. This was fixed in 1.3.0: before that, the old time stayed in the customer's calendar.

The file is written to the server's temporary directory — never to wp-content/uploads/, which is served publicly — through the WordPress filesystem API, and it is deleted as soon as sending finishes, including when sending fails. It carries the customer's name and email: it has no business staying on disk one second longer than necessary.


Reminders and the scheduled task

A single scheduled task, owag_hourly_event, runs every hour. It does three things.

1. The day-before reminder

The reminder goes out when the appointment starts in 12 to 36 hours. This is not a fixed time window, and that is deliberate: WP-Cron only fires when a page is visited. A site with no evening traffic used to lose the next day's reminders for good. With a sliding window, the reminder goes out on the first available run and catches up.

Conditions: the appointment is confirmed, an email address is filled in, it is not a blocked slot, and the reminder has not already been sent.

Each reminder is reserved atomically before sending (a conditional UPDATE on the reminder_sent flag): two overlapping cron runs cannot send the same message twice. If sending fails, the reservation is released and the reminder is retried on the next run. A short lock (10 minutes) further prevents two simultaneous runs from walking the same list.

Rescheduling an appointment resets the flag: the customer does receive a reminder for the new time.

2. Purging unverified requests

Requests that have stayed unverified for more than 48 hours are deleted. (Their slot, for its part, had already been released after 45 minutes.)

3. GDPR anonymisation

See the GDPR section.

The safety net

A WP-Cron event can vanish: database optimisation, a partial restore, a cleanup plugin, a reset of the cron option. OW Agenda checks on every load that the task is properly scheduled and reschedules it if not. Reminders, purging and retention cannot stop silently.

Using a real system cron

WP-Cron depends on traffic. On a site with few visitors, prefer a system cron:

// wp-config.php
define( 'DISABLE_WP_CRON', true );
# crontab -e — every 15 minutes
*/15 * * * * curl -s https://example.com/wp-cron.php?doing_wp_cron > /dev/null

Anti-spam and rate limits

Booking is public by nature: no account, no login, no nonce possible. Five independent protections therefore work upstream.

1. The bot trap (honeypot)

The form contains an off-screen text field, invisible to a human and appetising to a bot. The field must be present and empty. A filled field — or a missing one, which gives away a direct call to the API — returns a flat rejection (HTTP 403), with no exploitable detail.

2. Fill-in time

The page passes along the timestamp of when it was displayed. A submission arriving less than 4 seconds after load is rejected. Here too, a missing or malformed timestamp is a rejection: in 1.3.0 these two fields became mandatory — omitting them used to disable the check outright.

3. The hourly quota per address

5 bookings per hour per scope. On IPv4, the scope is the address; on IPv6, it is the /64 prefix — an attacker has 2⁶⁴ addresses there, and counting the full address would limit nothing.

The counter is only incremented after the form has passed validation: a visitor who mistypes their email three times in a row is no longer locked out for an hour over nothing.

4. The daily cap on unverified requests

At most 3 requests awaiting email verification may hold a slot on any single day. Without that cap, a handful of submissions was enough to freeze every slot on a date. Beyond it, the visitor is invited to try again later or to choose another day (HTTP 429).

5. The booking lock

Booking a slot and consuming an invitation both happen inside a critical section serialised by a named MySQL lock, whose name is derived from your database and your table prefix (so two sites on the same shared hosting never block each other). If the lock is not obtained within 5 seconds, the request returns an explicit HTTP 503 rather than a double booking.

Since 1.3.0, this lock replaces a transaction that relied on a bare SELECT: under REPEATABLE READ, that takes no row lock, and two simultaneous visitors could book the same time.

And double verification

This is the most effective layer: even after getting past everything above, a bot never reaches your agenda or your inbox, since the admin notification is only sent after a link received by email has been clicked. See Email double verification.


GDPR

An appointment agenda handles potential health data, contact details and histories. The default settings are deliberately conservative.

Consent

The consent checkbox is mandatory and unticked by default on the public form: "I agree that my details may be used to manage my appointment." A booking without consent is rejected server-side, not just in the browser.

Minimisation

  • No IP address is stored in the database. The hourly quota uses a temporary counter (a transient) whose key is a digest of the network scope, purged after an hour.
  • No user agent, no tracker, no cookie set by the plugin.
  • No data is sent to a third-party service: no CDN, no external call, no telemetry.
  • Requests that were never verified are deleted after 48 hours — they leave nothing behind.
  • Cancelled appointments are no longer returned by the agenda API: unnecessary personal data does not travel.

Automatic anonymisation

The retention_months setting, 24 months by default. Every hour, appointments that have been finished for longer than that are anonymised in place: the name is replaced with "Anonymised", the phone and email are emptied, the customer's message and the internal notes are wiped. The row remains, which preserves the statistical history of the business (how many appointments, when, for which service) without keeping a shred of personal data.

Set 0 to disable automatic anonymisation — with your eyes open.

Note: anonymisation targets appointments that carry an email address. An appointment typed in by hand with no email (name and phone only) is not reached by the automatic purge; delete it manually if your retention policy requires it.

Right of access and right to erasure

The plugin does not hook into WordPress's native exporters/erasers (those work on user accounts, and customers do not have one). To handle a request:

  • Access — search for the person in the customer directory (name, phone or email) and go through their appointments in the agenda. Database administrators can also query {prefix}_owag_appointments and {prefix}_owag_customers directly on the customer_email / email column.
  • Erasure — delete the directory record, then cancel the appointments concerned or let them be anonymised. Immediate deletion is done in the database on customer_email.
-- Manual erasure of one person's personal data (adjust the prefix).
UPDATE wp_owag_appointments
   SET customer_name = 'Anonymised', customer_phone = '', customer_email = '',
       customer_msg = NULL, admin_notes = NULL
 WHERE customer_email = 'person@example.com';

DELETE FROM wp_owag_customers WHERE email = 'person@example.com';

Record of processing activities

For your register: purpose appointment management; legal basis consent (online booking) or performance of a contract / pre-contractual measures (appointments taken by phone); categories identity, contact details, message content, internal notes; retention 24 months by default, then anonymisation; recipients no processor beyond your host and your email sending service.


Shortcodes

[ow_agenda] — visitor booking

To be placed on a public page. No attributes: the service, the duration and the hours all come from the settings. The shortcode loads its own stylesheet and script (under 12 KB in total, no dependencies) and renders a container that fills up after the first API call.

[ow_agenda]

This shortcode is also what interprets the ?owag_invite=<token> URL parameter: the same page serves the standard consultation and invitation-based sessions.

[ow_agenda_admin] — the management page

To be placed on a dedicated page. No attributes. The shortcode handles access itself:

  • logged-out visitor → a login link that returns to the page;
  • logged in without the owag_manage capability → a restricted access message;
  • logged in with the capability → the full interface (agenda, pending, customers, invitations).
[ow_agenda_admin]

Both pages are created automatically on activation with these shortcodes. If you have deleted them, simply create a page and paste the shortcode into it — then remember to update the owag_page_admin option (or owag_page_booking) so that the email links and the manifest point to the right place.

The standalone pages

Four URL parameters, intercepted on any page of the site, produce standalone pages served outside the theme (minimal rendering, noindex,nofollow):

URLRole
?owag_verify=<uid>Request verification by the customer (double opt-in)
?owag_cancel=<uid>Online cancellation by the customer
?owag_admin_action=confirm|refuse&uid=…&sig=…Confirm / decline from the admin email
?owag_manifest=1Web app manifest for the Agenda page

These pages never run an action on load: they show a summary and a button. No bot, no mail antivirus and no browser prefetch can confirm or cancel an appointment in a human's place.


REST API

Every endpoint lives under the owag/v1 namespace, at the usual REST root (https://example.com/wp-json/owag/v1/…).

Public endpoints

No authentication: these are the routes used by the booking widget and by the links received by email. Their security rests on random tokens (32 hexadecimal characters from a cryptographic generator), on HMAC signatures, and on the protections described in Anti-spam.

MethodPathParametersRole
GET/public/daysfrom (Y-m-d, default: today), token (invitation, optional)Returns label, duration and days: 14 days starting from from, each with its number of free slots
GET/public/slotsdate (Y-m-d, required), tokenReturns slots: the free start times (HH:MM)
POST/public/bookdate, time, name, phone, email, message, consent, website (honeypot), ts (timestamp), tokenCreates the request; returns status and the message to display
POST/public/verifyuidVerifies an unverified request
POST/public/canceluidCancels a pending or confirmed appointment, within the notice period
POST/public/admin-actionuid, action (confirm|refuse), sigConfirms or declines from the admin email (HMAC signature)

A booking example:

curl -X POST https://example.com/wp-json/owag/v1/public/book \
  -H 'Content-Type: application/json' \
  -d '{
    "date": "2026-09-15",
    "time": "14:30",
    "name": "Marie Dupont",
    "phone": "0470 12 34 56",
    "email": "marie@example.com",
    "message": "First visit",
    "consent": 1,
    "website": "",
    "ts": 1757942400
  }'
{
  "ok": true,
  "status": "unverified",
  "message": "One last step: an email has just been sent to you…"
}

Admin endpoints

All of them require the owag_manage capability and a valid WordPress REST nonce (X-WP-Nonce header).

MethodPathParametersRole
GET/admin/agendafrom, to (Y-m-d)events (the requested range, cancellations excluded) and pending (every upcoming pending request)
POST/admin/appointmentkind (manual|block|consult|seance), date, time, duration (5–720 min), label, name, phone, email, notes, force, send_emailCreates a manual appointment or a block
POST/admin/appointment/{id}status (pending|confirmed|cancelled|done|noshow), name, phone, email, label, notes, date, time, duration, force, silentUpdates, reschedules, changes the status. silent=1 suppresses every email
GET/admin/customersq (search)The directory (200 records maximum)
POST/admin/customersname (required), phone, email, notesCreates a record
POST/admin/customers/{id}sameUpdates a record
DELETE/admin/customers/{id}Deletes a record
GET/admin/invitesThe open invitations (never used or released, not expired)
POST/admin/invitesname (required), label (required), duration (15–720), days_valid (1–365, default 60), email, phone, send_emailCreates an invitation; returns url and email_sent
DELETE/admin/invites/{id}Deletes an invitation
GET/admin/settingsThe effective settings
POST/admin/settingsJSON body (see Settings reference)Saves the settings

Every route declares its access control explicitly; the capability is checked before a single piece of processing runs.

Error codes

Errors are standard WP_Error objects, with a readable code and an HTTP status.

CodeHTTPMeaning
owag_spam403Honeypot filled in, or timestamp missing / too recent
owag_rate429Hourly quota reached for this address
owag_day_full429Too many requests awaiting verification on this date
owag_busy503Booking lock not obtained — try again in a moment
owag_slot_taken409Slot taken in the meantime
owag_overlap409Overlap detected (admin) — resend with force
owag_invite_invalid404Unknown invitation token
owag_invite_used410Invitation already consumed
owag_invite_expired410Invitation expired
owag_too_late403Online cancellation outside the notice period
owag_bad_sig403Invalid HMAC signature
owag_not_found404Appointment or record not found
owag_name, owag_phone, owag_email, owag_consent400Invalid field or missing consent
owag_bad_input, owag_bad_date400Invalid date, time or input
owag_noop400Empty update request
owag_insert500Database write failed

Developer hooks

In 1.3.1, OW Agenda exposes neither a public filter nor a public action: there is no extension API to document, and nothing here has to be guessed. The only usable hook is the scheduled owag_hourly_event event, onto which you can attach your own housekeeping:

add_action( 'owag_hourly_event', function () {
    // Your hourly processing, run right after the plugin's own.
} );

For everything else, the REST API is the integration point: it exposes exactly what the interface does, no more and no less.


Settings reference

Every setting fits inside a single option, owag_settings. Reading and writing in PHP:

$s = owag_settings();          // effective settings (defaults + saved values)
$duration = (int) $s['consult_duration'];

update_option( 'owag_settings', array_merge( owag_settings(), [
    'consult_duration' => 45,
    'buffer'           => 10,
] ) );

Default values

[
    // Hours: key = 1 (Monday) … 7 (Sunday), value = list of ranges [start, end]
    'hours' => [
        '1' => [ [ '09:00', '18:00' ] ],
        '2' => [ [ '09:00', '18:00' ] ],
        '3' => [ [ '09:00', '18:00' ] ],
        '4' => [ [ '09:00', '18:00' ] ],
        '5' => [ [ '09:00', '18:00' ] ],
        '6' => [],   // Saturday closed
        '7' => [],   // Sunday closed
    ],
    'slot_step'           => 30,   // granularity of the offered slots (minutes)
    'consult_duration'    => 60,   // length of a consultation (minutes)
    'buffer'              => 0,    // buffer around each appointment (minutes)
    'min_notice_hours'    => 24,   // minimum notice before booking
    'max_advance_days'    => 60,   // maximum booking horizon
    'auto_confirm'        => 0,    // 0 = manual approval, 1 = automatic confirmation
    'email_verify'        => 1,    // email double verification
    'cancel_notice_hours' => 24,   // notice for online cancellation by the customer
    'notify_email'        => get_option( 'admin_email' ),
    'retention_months'    => 24,   // anonymisation of past appointments (0 = never)
    'consult_label'       => 'Consultation',
]

Details

SettingTypeInterface boundsEffect
hoursarray1 or 2 ranges per dayTwo ranges allow a lunch break. A range is only kept if start and end are valid and start < end. An empty day = closed
slot_stepinteger5 → 120 minSpacing between the offered times. The finer it is, the longer the list
consult_durationinteger15 → 480 minDuration booked in the agenda for a web consultation
bufferinteger0 → 120 minBuffer applied on either side of the candidate slot
min_notice_hoursinteger0 → 168 hNothing is offered before now + this delay
max_advance_daysinteger7 → 365 dHorizon beyond which no slot is ever offered
auto_confirm0 / 1Manual approval or immediate confirmation
email_verify0 / 1Email double opt-in (leave it on)
cancel_notice_hoursinteger0 → 168 hBeyond this, online cancellation is refused with a message inviting a phone call
notify_emailemailRecipient of the admin notifications; also used as ORGANIZER in the ICS files
retention_monthsinteger0 → 120 monthsAnonymisation of finished appointments; 0 disables it
consult_labeltextTitle displayed on the booking widget

Two non-configurable constants are worth knowing, because they govern how double opt-in behaves: the slot of an unverified request is held for 45 minutes, and the request is deleted after 48 hours.

Saving through the REST API accepts the same keys in JSON; integers are floored at zero, the notification address is only accepted if it is valid, and every hour range is re-validated server-side.


Stored data

Three tables, all carrying your installation's prefix. Dates are DATETIME values in the site's local time, never UTC — which is what makes the queries readable and time comparisons trivial.

{prefix}_owag_appointments

ColumnTypeNotes
idBIGINTPrimary key
uidCHAR(32)Unique public token (hexadecimal, cryptographic generator). Used as the key for verification, cancellation and admin action links, and as the ICS UID
kindVARCHAR(16)consult, seance, manual, block
service_labelVARCHAR(120)Displayed title
customer_name, customer_phone, customer_emailVARCHARContact details
customer_msgTEXTMessage left by the customer
admin_notesTEXTInternal notes, never sent to the customer
start_dt, end_dtDATETIMESlot boundaries (indexed on start_dt)
statusVARCHAR(16)unverified, pending, confirmed, cancelled, done, noshow
sourceVARCHAR(16)web, invite, admin
invite_idBIGINTOriginating invitation, where applicable
reminder_sentTINYINTReminder flag (reset to 0 on a reschedule)
created_at, updated_atDATETIMEupdated_at feeds the ICS revision number

{prefix}_owag_invites

id, token (CHAR(32), unique), the customer's contact details, service_label, duration_min, expires_at, appointment_id (filled in when the link is consumed, reset to NULL if the appointment is cancelled), created_at.

{prefix}_owag_customers

id, name, phone, email, notes, created_at, updated_at. Indexed on name and email.

A single option carries the configuration (owag_settings); the others are technical: owag_admin_secret (HMAC secret), owag_db_version, owag_pages_created, owag_page_booking, owag_page_admin.


Internationalization

The whole interface is translatable: admin settings, booking widget, Agenda page, standalone pages, API messages and email templates. The source language is English; the plugin declares the ow-agenda text domain and the /languages path.

The package ships the languages/ow-agenda.pot template. A complete French translation is provided, produced from the original wording of the interface.

Installing a translation manually

Compile the .po and drop the .mo into the WordPress translations folder:

msgfmt ow-agenda-fr_FR.po -o ow-agenda-fr_FR.mo
# then: wp-content/languages/plugins/ow-agenda-fr_FR.mo

WordPress loads the catalogue on the first translated string; no manual call is needed.

Two pitfalls for translators

  • Strings destined for JavaScript use numbered placeholders (%1$s, %2$s) and never a bare %s: the small formatter embedded in the scripts understands nothing else. An unnumbered placeholder would be displayed literally.
  • Two strings are PHP date formats, not sentences: l, F j, Y \a\t g:i a and l j F Y \a\t H:i. Translate them as formats (reorder the tokens, escape literal letters with a backslash). French uses l j F Y \à H\hi.

Uninstalling

Deactivating the plugin does exactly one thing: remove the hourly scheduled task. Your data, your settings and your pages stay intact, and everything starts again on reactivation.

Deleting the plugin from WordPress runs the full uninstall:

  • the three tables are dropped (owag_appointments, owag_invites, owag_customers);
  • the owag_settings, owag_admin_secret, owag_db_version, owag_pages_created, owag_page_booking and owag_page_admin options are deleted;
  • the scheduled task is removed;
  • the owag_manage capability is taken off the administrator role.

The two pages created on activation are not deleted — they may have been reworked, added to a menu, indexed. It is up to you to move them to the trash if you no longer want them.

Dropping the tables is permanent. Export your database first if the appointment history has any value to you.


Troubleshooting

No slot appears on the booking page

Work down the list in order:

  1. Are the day's hours filled in? An empty day is a closed day.
  2. The minimum notice (24 h by default) necessarily hides today and part of tomorrow.
  3. Does the duration fit in the range? With a 09:00–12:00 range and a 240-minute duration, no slot can exist.
  4. The horizon: days beyond max_advance_days are never offered.
  5. The buffer: a wide buffer on a busy schedule can close everything.
  6. A blocked slot may be covering the day. Open the Agenda page on that date.
  7. The site's time zone (Settings → General): a badly set zone shifts every calculation.

Emails do not arrive

It is almost always deliverability, not the plugin.

  1. Check that the appointment does exist on the Agenda page: if it is there, the plugin has done its job.
  2. Install an SMTP plugin (Fluent SMTP, WP Mail SMTP…). PHP's mail() function is rejected by the vast majority of receiving servers.
  3. Check your domain's SPF, DKIM and DMARC records.
  4. Test the address in OW Agenda → Admin notifications sent to: an invalid address silently blocks notifications.

The day-before reminder did not go out

The reminder depends on WP-Cron, which depends on traffic. On a site with few visitors, switch to a real system cron (see Reminders and the scheduled task). Also check that the appointment is indeed confirmed, that it carries an email address and that it is not a blocked slot.

The Agenda page shows "Access is restricted to the team"

The logged-in user does not have the owag_manage capability. It is granted to administrators on activation; for other roles, see Installation.

The "Confirm" button in the email shows "Invalid link"

Three possible causes: the link was truncated by the mail client (try copying the full URL), the appointment has already been handled (the message says so), or the site's HMAC secret was regenerated. In that last case, use the Agenda page: the old links can no longer be validated, by design.

Two appointments end up on the same slot

Since 1.3.0, this is impossible through online booking. The admin can force an overlap from the Agenda page, though (the plugin flags it and asks for confirmation): check first that it was not a deliberate override. Also check that your database properly supports GET_LOCK() — exotic hosting would then return 503 errors on booking rather than duplicates.

The "Book" and "Agenda" pages were not created

They are only created once, on the very first activation. If you have deleted them, create two pages with the [ow_agenda] and [ow_agenda_admin] shortcodes, then point the owag_page_booking and owag_page_admin options at them so that the email links stay correct:

update_option( 'owag_page_booking', 42 );  // ID of your booking page
update_option( 'owag_page_admin', 43 );    // ID of your Agenda page

The calendar file is not attached

The file is written to the server's temporary directory through the WordPress filesystem API. If that directory is not writable (quota, permissions, locked-down hosting), the email goes out without the attachment rather than failing. Check write permissions and disk space.

The times are off by an hour

Settings → General → Timezone. Pick a city (Europe/Brussels) rather than a fixed UTC offset: only a named time zone handles the switch to summer time.


FAQ

Is OW Agenda really free? Yes. GPL-2.0-or-later, downloadable as a .zip from https://optionweb.dev/en/addons/ow-agenda/. No Pro version, no license key, no locked feature, no account to create, no appointment quota.

Do the online slots take appointments booked by phone into account? Yes. Every event in the agenda — web booking, appointment typed in by hand, invitation session, blocked slot — makes its time range unavailable for online booking.

Does the customer have to create an account? No. No account, no password. Every customer action (verification, cancellation) goes through private links received by email.

Can it handle several practitioners or several rooms? No. OW Agenda describes a single schedule. For two independent agendas you need two sites (or two sites in a multisite network).

Does it synchronise with Google Calendar? In the direction that matters to the customer: yes. Every confirmation carries a standard calendar file that adds — and updates, and removes — the event in Google Calendar, Outlook or the iPhone calendar. The plugin does not read your external calendars, though: your personal unavailability is entered as blocked slots.

Can a deposit be charged at booking time? No, that is outside the plugin's stated scope.

How many services can be offered online? One, whose label and duration you choose. Other services go through session invitations, which let you set the title and the duration case by case. That choice is deliberate: a menu of services on a public page multiplies badly targeted requests.

Does it work on multisite? Yes. Each site in the network has its own tables, its own settings and its own agenda.

Does the plugin slow the site down? No. No script is loaded outside the two pages concerned; the booking widget weighs under 12 KB, with no jQuery and no framework. The periodic work fits into one hourly task.

What happens if a customer never verifies their email? Their slot is released after 45 minutes and their request is deleted after 48 hours. You never hear about it.

Where is the support?


Changelog

1.3.1

Compliance

  • The standalone action, verification and cancellation pages no longer print their CSS or their JavaScript inline: both are now registered files served through the WordPress enqueue API. The two variable button colours go through custom CSS properties, and the script is configured with wp_localize_script().
  • Translation catalogues are no longer bundled: only languages/ow-agenda.pot ships, with French distributed through the WordPress.org language packs fed by translate.wordpress.org.
  • The minimum WordPress version goes back to 6.3: nothing in the package depends any longer on the bundled-translation discovery introduced in 6.7.

REST API

  • Every admin route now declares its permission_callback explicitly instead of sharing it through array_merge(). The capability check was already applied; it is now visible to static analysis.

No behaviour changes.

1.3.0

Internationalization

  • The plugin is fully translatable (the ow-agenda text domain, English as the source language): settings, public pages, API messages, emails and the JavaScript interface.
  • The languages/ow-agenda.pot template ships, along with a complete French translation.

Security

  • Public appointment identifiers and invitation tokens are generated by a cryptographic generator (random_bytes()). Those tokens are the only authentication behind the cancellation, verification and invitation links.
  • The bot trap and the timestamp became mandatory: omitting them used to disable the anti-spam checks.
  • The hourly quota is aggregated by /64 prefix on IPv6, and a daily cap limits unverified requests — a visitor can no longer hold every slot on a given day.
  • The quota is only counted after the form has passed validation: a typo no longer locks a legitimate visitor out for an hour.

Reliability

  • Simultaneous bookings of the same slot are serialised by a named MySQL lock. The previous transaction took no row lock: two visitors could book the same time.
  • The day-before reminder uses a sliding 12-to-36-hour window and catches up when it falls behind; each send is reserved atomically, so two cron runs can no longer send the same reminder twice.
  • The hourly task reschedules itself automatically if it disappears: reminders, purging and anonymisation can no longer stop silently.
  • Rescheduling an appointment updates the customer's calendar (SEQUENCE, METHOD:REQUEST, organizer and attendee); cancellation sends a METHOD:CANCEL that removes the event.
  • Widget dates are computed in local time rather than UTC: no more lost day for visitors east of UTC.
  • Cancelling an appointment that came from an invitation releases the invitation, which becomes usable again.
  • Cancelled appointments and expired unverified requests are no longer returned to the agenda: less personal data in the responses, and no more ghost appointments.

1.2.1

  • Plugin Check compliance: output escaping, input sanitisation, nonce on the settings, readme.
  • Uninstalling now also drops the customer directory table and the HMAC secret.

1.2.0

  • HTML emails (card layout, the site's accent colour) for every notification.
  • Confirm / Decline buttons in the admin email (HMAC-signed links).
  • Customer directory: search, create, edit, delete, shortcuts to a new appointment and to an invitation.

1.1.0

  • Email double verification against bots (slot held for 45 minutes, purge after 48 hours).

1.0.0

  • Initial release: online booking, mobile-first admin agenda, session invitations, emails with a calendar file and the day-before reminder.

Built by OptionWeb — Julien Daniel, Châtelet, Belgium.