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
- Overview
- Installation
- Quick start
- The booking journey
- Email double verification
- Approving requests
- The Agenda page
- The customer directory
- Session invitations
- Emails and calendar file (ICS)
- Reminders and the scheduled task
- Anti-spam and rate limits
- GDPR
- Shortcodes
- REST API
- Settings reference
- Stored data
- Internationalization
- Uninstalling
- Troubleshooting
- FAQ
- 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/v1namespace
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
- Download
ow-agenda-1.3.1.zipfrom https://optionweb.dev/en/addons/ow-agenda/ - Plugins → Add New → Upload Plugin
- 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:
| Table | Contents |
|---|---|
{prefix}_owag_appointments | Every event in the agenda: web consultations, sessions created from an invitation, appointments typed in by hand, blocked slots |
{prefix}_owag_invites | The private "choose the date of your session" links |
{prefix}_owag_customers | The 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_managecapability to the administrator role — that capability, and notmanage_options, is what opens the agenda and the admin REST routes; - the default settings in the
owag_settingsoption; - 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:
- it falls inside an opening range for that day;
- the whole appointment (start + duration) fits before closing time;
- it starts after now + minimum notice;
- the day is inside the booking horizon;
- 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:
| Configuration | Status created | Message shown |
|---|---|---|
| Email double verification on (default) | unverified | "One last step: an email has just been sent to you…" |
| Verification off + manual approval | pending | "Your request has been received!" |
| Verification off + automatic confirmation | confirmed | "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):
- The request is recorded with the
unverifiedstatus. The administrator is not notified. The appointment does not show up as a request to handle. - The visitor receives a "One last step…" email containing a verification button.
- 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.
- 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. - The request then becomes
pending(manual approval) orconfirmed(automatic confirmation), the customer receives the matching email, and only then does the admin notification go out. - 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:
- 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_secretoption (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:
- 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.
- Choose how long the link stays valid: 60 days by default, from 1 to 365.
- 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.
- 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.
| Recipient | Trigger | |
|---|---|---|
| Verify your request | Customer | Web booking when double verification is on |
| Request received | Customer | Request verified, awaiting your decision |
| Appointment confirmed | Customer | Confirmation (manual, automatic, admin entry, reschedule) — with ICS attachment |
| Appointment cancelled | Customer | Cancellation by the customer, decline or cancellation by the admin — with cancellation ICS attachment |
| Reminder | Customer | Automatically, between 12 and 36 h before the appointment |
| Choose the date of your session | Customer | An invitation is created with email sending enabled |
| To confirm: … | Admin | New verified request pending — with the Confirm / Decline buttons |
| New appointment: … | Admin | New confirmed appointment (automatic confirmation, invitation) |
| Customer cancellation: … | Admin | The 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:REQUESTon creation and on every schedule revision,METHOD:CANCELon cancellation;- a stable
UIDderived 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
SEQUENCEderived 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) andATTENDEE(the customer);DTSTART/DTENDin UTC,STATUS:CONFIRMEDorSTATUS: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_appointmentsand{prefix}_owag_customersdirectly on thecustomer_email/emailcolumn. - 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_managecapability → 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):
| URL | Role |
|---|---|
?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=1 | Web 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.
| Method | Path | Parameters | Role |
|---|---|---|---|
GET | /public/days | from (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/slots | date (Y-m-d, required), token | Returns slots: the free start times (HH:MM) |
POST | /public/book | date, time, name, phone, email, message, consent, website (honeypot), ts (timestamp), token | Creates the request; returns status and the message to display |
POST | /public/verify | uid | Verifies an unverified request |
POST | /public/cancel | uid | Cancels a pending or confirmed appointment, within the notice period |
POST | /public/admin-action | uid, action (confirm|refuse), sig | Confirms 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).
| Method | Path | Parameters | Role |
|---|---|---|---|
GET | /admin/agenda | from, to (Y-m-d) | events (the requested range, cancellations excluded) and pending (every upcoming pending request) |
POST | /admin/appointment | kind (manual|block|consult|seance), date, time, duration (5–720 min), label, name, phone, email, notes, force, send_email | Creates 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, silent | Updates, reschedules, changes the status. silent=1 suppresses every email |
GET | /admin/customers | q (search) | The directory (200 records maximum) |
POST | /admin/customers | name (required), phone, email, notes | Creates a record |
POST | /admin/customers/{id} | same | Updates a record |
DELETE | /admin/customers/{id} | — | Deletes a record |
GET | /admin/invites | — | The open invitations (never used or released, not expired) |
POST | /admin/invites | name (required), label (required), duration (15–720), days_valid (1–365, default 60), email, phone, send_email | Creates an invitation; returns url and email_sent |
DELETE | /admin/invites/{id} | — | Deletes an invitation |
GET | /admin/settings | — | The effective settings |
POST | /admin/settings | JSON 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.
| Code | HTTP | Meaning |
|---|---|---|
owag_spam | 403 | Honeypot filled in, or timestamp missing / too recent |
owag_rate | 429 | Hourly quota reached for this address |
owag_day_full | 429 | Too many requests awaiting verification on this date |
owag_busy | 503 | Booking lock not obtained — try again in a moment |
owag_slot_taken | 409 | Slot taken in the meantime |
owag_overlap | 409 | Overlap detected (admin) — resend with force |
owag_invite_invalid | 404 | Unknown invitation token |
owag_invite_used | 410 | Invitation already consumed |
owag_invite_expired | 410 | Invitation expired |
owag_too_late | 403 | Online cancellation outside the notice period |
owag_bad_sig | 403 | Invalid HMAC signature |
owag_not_found | 404 | Appointment or record not found |
owag_name, owag_phone, owag_email, owag_consent | 400 | Invalid field or missing consent |
owag_bad_input, owag_bad_date | 400 | Invalid date, time or input |
owag_noop | 400 | Empty update request |
owag_insert | 500 | Database 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
| Setting | Type | Interface bounds | Effect |
|---|---|---|---|
hours | array | 1 or 2 ranges per day | Two ranges allow a lunch break. A range is only kept if start and end are valid and start < end. An empty day = closed |
slot_step | integer | 5 → 120 min | Spacing between the offered times. The finer it is, the longer the list |
consult_duration | integer | 15 → 480 min | Duration booked in the agenda for a web consultation |
buffer | integer | 0 → 120 min | Buffer applied on either side of the candidate slot |
min_notice_hours | integer | 0 → 168 h | Nothing is offered before now + this delay |
max_advance_days | integer | 7 → 365 d | Horizon beyond which no slot is ever offered |
auto_confirm | 0 / 1 | — | Manual approval or immediate confirmation |
email_verify | 0 / 1 | — | Email double opt-in (leave it on) |
cancel_notice_hours | integer | 0 → 168 h | Beyond this, online cancellation is refused with a message inviting a phone call |
notify_email | — | Recipient of the admin notifications; also used as ORGANIZER in the ICS files | |
retention_months | integer | 0 → 120 months | Anonymisation of finished appointments; 0 disables it |
consult_label | text | — | Title 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
| Column | Type | Notes |
|---|---|---|
id | BIGINT | Primary key |
uid | CHAR(32) | Unique public token (hexadecimal, cryptographic generator). Used as the key for verification, cancellation and admin action links, and as the ICS UID |
kind | VARCHAR(16) | consult, seance, manual, block |
service_label | VARCHAR(120) | Displayed title |
customer_name, customer_phone, customer_email | VARCHAR | Contact details |
customer_msg | TEXT | Message left by the customer |
admin_notes | TEXT | Internal notes, never sent to the customer |
start_dt, end_dt | DATETIME | Slot boundaries (indexed on start_dt) |
status | VARCHAR(16) | unverified, pending, confirmed, cancelled, done, noshow |
source | VARCHAR(16) | web, invite, admin |
invite_id | BIGINT | Originating invitation, where applicable |
reminder_sent | TINYINT | Reminder flag (reset to 0 on a reschedule) |
created_at, updated_at | DATETIME | updated_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 aandl j F Y \a\t H:i. Translate them as formats (reorder the tokens, escape literal letters with a backslash). French usesl 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_bookingandowag_page_adminoptions are deleted; - the scheduled task is removed;
- the
owag_managecapability 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:
- Are the day's hours filled in? An empty day is a closed day.
- The minimum notice (24 h by default) necessarily hides today and part of tomorrow.
- Does the duration fit in the range? With a 09:00–12:00 range and a 240-minute duration, no slot can exist.
- The horizon: days beyond
max_advance_daysare never offered. - The buffer: a wide buffer on a busy schedule can close everything.
- A blocked slot may be covering the day. Open the Agenda page on that date.
- 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.
- Check that the appointment does exist on the Agenda page: if it is there, the plugin has done its job.
- Install an SMTP plugin (Fluent SMTP, WP Mail SMTP…). PHP's
mail()function is rejected by the vast majority of receiving servers. - Check your domain's SPF, DKIM and DMARC records.
- 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?
- Support portal: https://optionweb.dev/en/addons/support/
- Email: support@optionweb.dev
- Documentation: https://optionweb.dev/en/addons/ow-agenda/
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.potships, 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_callbackexplicitly instead of sharing it througharray_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-agendatext domain, English as the source language): settings, public pages, API messages, emails and the JavaScript interface. - The
languages/ow-agenda.pottemplate 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 aMETHOD:CANCELthat 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.