Slotly Manual
Slotly — User Manual
Appointment booking you own outright. Version 4.1.3 [src: app/index.php:12].
About this manual
Every statement here was written by reading Slotly's own source, and each one carries the file it came from in a bracketed src marker. Paths are relative to the folder holding this docs directory. Where the application and this manual disagree, the application is right — the markers exist so you can check.
No feature is described that the code does not provide. Where something is off until you enable it, or is deliberately capped, that is stated rather than glossed.
What Slotly is
Slotly is a booking system with two faces: a public page where your customers book themselves in, and an admin area where you run the diary [src: app/controllers/public.php:140].
It is a single-business installation — one diary, one team, one database [src: app/src/Auth.php:15]. It runs on PHP with either SQLite or MySQL and needs no Composer, no Node and no build step [src: app/install/index.php].
Installing
Point a web server at the app directory and follow the installer [src: app/install/index.php]. Schema creation and later upgrades are handled by the application itself [src: app/src/Database.php:147], so an upgrade is a matter of replacing files.
Signing in
The admin area is at /admin/login [src: app/controllers/admin.php]. Repeated failures are rate-limited [src: app/src/LoginRate.php].
Two-step verification is available per user and uses standard time-based codes, so any authenticator application works [src: app/src/Totp.php]. Enrolling shows recovery codes once; only their hashes are kept, so they cannot be shown again [src: app/src/Totp.php].
Signing in with it on. Once a person has switched two-step verification on, the right password alone does not sign them in: the next screen, /admin/login/2fa, asks for the code from their authenticator app [src: app/controllers/admin.php:25], or one of their recovery codes, which is used up once accepted [src: app/controllers/admin.php:54]. Before 4.1.1 the code was never asked.
Single sign-on through OpenID Connect is supported, starting at /admin/login/sso and returning to /admin/login/sso/callback [src: app/controllers/v3.php].
Roles and permissions
Slotly has three roles — viewer, staff and admin [src: app/src/Auth.php:15] — and, unlike a simple two-tier system, it gates individual actions through a permission map rather than by role name alone [src: app/controllers/api.php:27].
The roles are described in the source in the terms a salon or clinic actually uses: a viewer is read-only, a member of staff runs the desk, and an admin is the owner [src: app/controllers/api.php:20].
What each role may do
This table was produced by running Slotly's own permission function against its own map, not by reading role names:
| Action | viewer | staff | admin |
|---|---|---|---|
| View appointments, calendar, reports | yes | yes | yes |
| View the audit log | yes | yes | yes |
| Create an appointment | — | yes | yes |
| Edit an appointment | — | yes | yes |
| Record a payment | — | yes | yes |
| Edit a customer | — | yes | yes |
| Delete a customer | — | — | yes |
| Change settings | — | — | yes |
| Manage the team | — | — | yes |
[src: app/controllers/api.php:27]
Two consequences are worth knowing before you hand out a role.
A viewer can read the audit log. The viewer grant is *.view, which matches any permission ending in .view — and that includes audit.view [src: app/controllers/api.php:30]. If you intend a viewer to be an accountant or a partner who sees the diary but not the history of who changed what, that is not what this role does.
Settings are the largest admin-only surface. Of the permissions the code actually enforces, settings.edit is required in seventeen places, team.manage in four, and customer.delete and audit.view in two each [src: app/controllers/api.php:39].
A refusal renders inside the admin chrome rather than as a bare page, deliberately, because a blank 403 reads as a broken link [src: app/controllers/api.php:44].
Services and categories
Services are the things customers book, and each carries at least a name and a duration [src: app/src/Schedule.php:52]. They can be grouped into categories [src: app/controllers/admin.php]. Creating, editing and deleting services and categories are all admin-only [src: app/controllers/admin.php].
The team and who can be booked
Each team member is a user with a role, an optional personal time zone, a color for the calendar, and a flag for whether they may be booked at all [src: app/src/Database.php:147]. A member who is not bookable still has an account but does not appear as an option to customers.
Which staff can perform a given service is resolved per service [src: app/src/Schedule.php:35].
How an available slot is worked out
This is the heart of the product and it is worth understanding, because four settings shape every slot a customer sees [src: app/src/Schedule.php:52]:
| Setting | Default | Effect |
|---|---|---|
| Slot step | 15 minutes | the granularity of offered start times [src: app/src/Database.php:147] |
| Lead time | 120 minutes | how far ahead of now the earliest bookable slot sits [src: app/src/Database.php:147] |
| Maximum advance | 60 days | how far into the future booking is allowed [src: app/src/Database.php:147] |
| Business time zone | UTC | the zone the diary is reckoned in [src: app/src/Database.php:147] |
| Most appointments at the same time (3.3) | 0 (no limit) | across the whole business: a time already this full is not offered to anyone; a group class counts once, and more seats in it are still sold [src: app/src/Schedule.php overCap] |
Appointments are stored in UTC and presented in the business's zone [src: app/src/Schedule.php:29], which is what keeps a diary correct across a daylight-saving change.
Before a booking is accepted the chosen slot is validated again on the server, against the service, the member of staff, the party size and the current time [src: app/src/Schedule.php:166]. That second check is what prevents two customers who loaded the page at the same moment from taking the same slot.
Taking a booking
The public page
Your customers start at / [src: app/controllers/public.php]. The page is driven by three small endpoints it calls as the customer chooses: /api/staff for who can do the job, /api/slots for when, and /api/quote for what it will cost [src: app/controllers/public.php]. The booking itself is posted to /api/book [src: app/index.php:122].
A successful booking returns the customer's own booking link [src: app/controllers/public.php:189].
Guarded against abuse (4.1.2). /api/book stays open to anyone, as the storefront must be, with three checks first [src: app/controllers/public.php:98]. One address can post at most 30 bookings in 10 minutes; the next is answered "Too many requests" until the window has passed [src: app/controllers/public.php:341] [src: app/controllers/public.php:347]. A browser post sent from another website is refused [src: app/controllers/public.php:349]: the browser names the page it came from, and it has to be this site. The booking page has a hidden field that a person never sees and only a bot fills in; a booking with it filled is refused and nothing is written [src: app/views/public/home.php:80] [src: app/controllers/public.php:352]. A program that books from a server, with no browser, books as before. If Slotly sits behind Cloudflare, a load balancer or another proxy, set trusted_proxies (see "Behind Cloudflare or a load balancer" below), or every customer counts as the same address.
Approval mode
By default a booking is taken as final. Turn on require approval and bookings arrive needing your confirmation instead [src: app/src/Database.php:147].
Statuses
An appointment is pending, confirmed, cancelled, completed or no_show [src: app/controllers/public.php:160].
Deposit to confirm (no-show protection without a card)
Without card payments switched on (next section), Slotly takes no card. What it can do is hold a slot until the deposit has arrived through your own payment link [src: app/src/Hold.php].
- Per service. Tick Deposit to confirm on a service that has a deposit (Services → edit). A booking made on the public page is then
pendingand keeps its slot, so nobody else can take it, until you record the deposit as paid [src: app/controllers/public.php, app/src/Hold.php]. - Recording the deposit confirms it. When the payments recorded on the booking cover the deposit, the booking becomes
confirmedby itself, the customer gets the confirmation email andappointment.confirmedis sent to your webhooks [src: app/controllers/admin.php, app/src/Hold.php]. Confirming it by hand also ends the hold. - An unpaid hold lapses. After Unpaid hold is released after (hours) (24 by default, never later than the appointment itself) the booking is canceled, the customer is told, and the time goes back on sale and is offered to the waitlist [src: app/src/Hold.php]. This happens on the reminder cron, and also whenever the booking page or your dashboard is opened, so it works without a cron too.
- No-show rule. Set Deposit to confirm after this many no-shows to 2, say, and a customer with two no-shows is asked for the deposit first on every service that has one; on a service without a deposit, their booking waits for your approval. Everyone else books as before. 0 switches it off [src: app/src/Hold.php].
- No-show fee. Set No-show fee in Settings → No-show protection (0, the default, is off). On an appointment marked no-show, Email the no-show fee sends the customer one email: the fee less any deposit already paid, the cancellation policy they accepted when they booked, and your payment link [src: app/src/NoShowFee.php]. It is recorded on the appointment and cannot be sent twice; if the email cannot be sent, nothing is recorded and you can try again. It needs the customer's email and your payment link, and the payment role (
payment.record). Record the fee with Record payment when it arrives. - Cancellation policy. Write your policy in Settings → No-show protection and the booking page shows it with a tick box. A booking cannot be made without the tick, and each booking keeps the exact text the customer accepted and when; you see it on the appointment and the customer sees it on their booking page [src: app/controllers/public.php, app/src/Hold.php].
Because a pasted payment link cannot tell Slotly it was paid, confirmation is your click on Record payment. The customer's booking page and confirmation email show the deposit, the time the hold ends and your payment link.
Holds apply to bookings made on the public page. Bookings you make yourself, or through the REST API or an AI assistant, are confirmed as before.
Card deposits and prepayment at booking (your own Stripe account, from 4.1)
A customer can pay a deposit, or the whole price, by card while booking. The payment goes through your own Stripe account: the customer types the card into Stripe's own page, never into Slotly, and the money goes from Stripe to your bank. Slotly takes no cut and stores no card [src: app/src/Pay.php].
It is off until you switch it on. To set it up:
- In Stripe, open Developers → API keys and copy your secret key (
sk_live_…, orsk_test_…to try it in test mode). A restricted key (rk_…) that may create Checkout Sessions also works. - In Stripe, open Developers → Webhooks and add an endpoint at
https://your-slotly-address/hooks/stripe. Choose the eventscheckout.session.completed,checkout.session.async_payment_succeededandcheckout.session.expired. Copy the endpoint's signing secret (whsec_…). - In Slotly, open Settings → Card payments at booking. Tick Take card payments at booking, paste the secret key and the signing secret, choose the currency your prices are in, and set Time to pay (30 to 60 minutes, 30 by default). Save. The section then says Ready, or lists exactly what is still missing [src: app/views/admin/settings.php, app/controllers/admin.php].
- On each service that takes a card payment (Services → edit), set Online payment at booking to Deposit by card or Full prepayment by card. Deposit by card charges the deposit set on that service (a fixed amount or a percentage), so the service needs one. Services left on No online payment book exactly as before [src: app/controllers/admin.php].
The two keys are saved write-only: the form never shows them again, and leaving a field blank keeps the saved key. Both are left out of the JSON backup [src: app/controllers/api.php]. Test keys work the same way as live keys, so you can try the whole flow with Stripe's test cards first.
What the customer sees. On the booking page, the summary shows Deposit by card now or Pay now by card with the amount, and that the time is held while they pay; the button reads Continue to payment. Pressing it saves the booking and sends the customer to Stripe's page [src: app/assets/booking.js, app/controllers/public.php].
What happens to the booking.
- The booking is saved as
pendingand holds its slot, so nobody else can take it. The Stripe page is set to close at a fixed moment: the time to pay, plus one minute because Stripe will not accept a page that closes sooner than 30 minutes after it makes it. The hold ends at that same moment [src: app/src/Pay.php]. - The booking is confirmed only when Stripe's signed message (the webhook) arrives. Coming back from Stripe's page proves nothing on its own: if the customer returns before the message, their booking page says it is waiting for Stripe to confirm the payment. If the message arrives first, the booking is already confirmed when they return [src: app/src/Pay.php, app/views/public/booking.php].
- Each message is checked against your signing secret over the exact bytes Stripe sent, and refused if its time is more than five minutes away from now. Each message, and each Stripe page, is recorded at most once, so Stripe's retries never record a payment twice [src: app/src/Pay.php].
- When the payment is recorded, it appears on the appointment as Card (Stripe) with Stripe's checkout session reference, in the same payments list as any other payment. The booking becomes
confirmed, and the confirmation email goes to the customer and to your business address. It says what was paid by card and what is left to pay at the visit. Your webhooks receivepayment.recordedandappointment.confirmed[src: app/src/Pay.php, app/views/emails/confirmation.php]. - No confirmation email is sent when the booking is saved; it is sent when the payment arrives.
If the customer does not pay. When Stripe reports the page expired unpaid, the hold is released at once: the booking is canceled, the customer gets the cancellation email, and the time goes back on sale and is offered to the waitlist. If that message never comes, the same happens five minutes after the hold ends, so a payment already on its way is not lost [src: app/src/Pay.php, app/src/Hold.php]. While the hold lasts, the customer can press Pay by card on their booking page to get a new Stripe page. A new page closes the previous one and starts a new time to pay. A booking can ask for three pages at most [src: app/src/Pay.php].
A payment that arrives too late. A payment can still arrive after the booking stopped waiting for it: after its hold was released, after it was canceled, or for a second page when the first was already paid. Slotly never turns such a payment back into a booking, because the time may already belong to someone else. It records the payment as Refund due, which does not count as paid. It adds a note to the appointment, emails the customer an apology saying the money will be refunded, and emails your business address a notice with the Stripe reference [src: app/src/Pay.php, app/views/emails/refund_due.php].
Refunds. Slotly never moves money, so every refund is made in your Stripe dashboard (Payments, then the payment, then Refund). Then open the appointment in Slotly (Appointments, then the booking) and press Mark refunded on that card payment in the Payment box. A payment that counted toward the booking comes off what was paid; a Refund due payment is marked refunded. It needs the payment role (payment.record) [src: app/controllers/admin.php].
Canceling. When you or the customer cancel a booking whose card payment is still open, Slotly closes its Stripe page, so it cannot be paid afterwards [src: app/controllers/public.php, app/controllers/admin.php].
How it fits with the other rules.
- On a service with card payment, the card payment is the hold. A customer past your no-show limit also pays by card on that service.
- On a service without card payment, Deposit to confirm and the no-show rule work exactly as described above.
- With Require approval on, a booking paid by card is confirmed when the payment arrives, the same as a deposit you record by hand.
- Only bookings made on the public page are asked to pay by card. Bookings you make yourself, or through the REST API or an AI assistant, are not.
- Charges are made in one currency, chosen from USD, GBP, EUR, CAD, AUD, NZD, CHF, SEK, NOK, DKK, PLN, CZK, RON, SGD, HKD, ZAR and MXN [src: app/src/Pay.php].
Repeating appointments
On New appointment, choose Repeat (every week, or every 2, 3 or 4 weeks) and, if you like, Until a date [src: app/src/Series.php]. The first appointment is booked as usual; the rest of the series is booked through the same engine as far ahead as your booking window allows (Maximum advance), and the reminder cron adds new dates as they come into the window, so a series with no end date keeps going. A date that clashes with time off, another booking or working hours is skipped and listed, never forced in. Times are the local time in your time zone, so 10:00 stays 10:00 across a clock change. From any appointment in the series, End series stops it and can also cancel the upcoming appointments (their customers are told and the seats reopen).
A staff member's own calendar
On a staff member's page, paste the private .ics address of their own Google, Outlook or Apple calendar [src: app/src/IcsBusy.php]. Slotly reads it when you save, on Read their calendar now, and on every run of the reminder cron, and turns its events into busy time that the booking page will not offer. It understands events in UTC, with a time zone or all-day, events with a duration, weekly, daily and monthly repeats with an end date or a count, exceptions and moved occurrences; canceled events and events marked free do not block. The address is write-only on the page (like a password), must be https (webcal:// is read as https://) and may not point at a private network; it is left out of the shareable JSON backup. Busy blocks read from the calendar do not appear in the manual time-off list.
Booking questions and video rooms
On a service, Booking questions takes one question per line; end a line with * to make it required [src: app/src/Intake.php]. The booking page asks them with the customer's details, and the answers are stored with the exact question text and shown on the appointment, in your new-booking email and in the bookings CSV. Video meeting link (https) is your own room for online sessions; it goes in the confirmation and reminder emails, on the customer's booking page and as the location in the calendar feed.
The waitlist
When a day is fully booked, the booking page offers Join the waitlist for that service and day (with the chosen staff member, or anyone). It is offered only on days somebody who does the service works [src: app/src/Waitlist.php, app/assets/booking.js].
When a booking on that day is canceled — by the customer, by you, or because a deposit hold lapsed — the first person waiting who fits is emailed a link, and texted as well if they asked and text reminders are on [src: app/src/Waitlist.php]. The link opens the booking page on that service and day with their details filled in. The booking itself goes through the same engine as every other, so an offer can never double-book: whoever confirms first gets the place.
Waitlist in the sidebar lists who is waiting; staff can Offer now (send the link without waiting for a cancellation) or Remove an entry [src: app/controllers/admin.php, app/views/admin/waitlist.php]. One email address can wait for at most five days at once, and the public form is rate-limited.
The customer's own booking page
Every appointment has a short code and its own cancellation token, and the two do different jobs [src: app/controllers/public.php:138].
- The code alone shows the booking.
/b/{code}renders the appointment read-only [src: app/controllers/public.php:142]. - The code plus the token allows managing it. Whether the cancel and reschedule controls appear is decided by comparing the supplied token in constant time [src: app/controllers/public.php:145].
Acting on the booking requires the token without exception: canceling and rescheduling both refuse with HTTP 403 if it is absent or wrong [src: app/controllers/public.php:155]. So a customer who forwards their confirmation e-mail shares the ability to cancel; a link with the code alone does not.
Canceling and rescheduling
Both can be switched off entirely, independently of each other [src: app/src/Database.php:148].
When cancellation is allowed, three things are checked before it goes through [src: app/controllers/public.php:159]:
- Online cancellation must be enabled.
- The appointment must not already be canceled, completed, or marked as a no-show.
- It must be further away than the cancellation cutoff, which defaults to 24 hours [src: app/src/Database.php:149].
A customer who is inside the cutoff is told to contact you rather than being silently refused [src: app/controllers/public.php:163].
A cancellation releases the slot back into availability and notifies [src: app/controllers/public.php:165]. Rescheduling moves the appointment, re-validating the new time exactly as a fresh booking would be [src: app/src/Schedule.php:293].
Prices, tax and coupons
A quote is calculated from the service, the party size and any coupon code [src: app/src/Pricing.php:10]. Coupons are validated against the amount, so a minimum-spend coupon cannot be applied to a smaller booking [src: app/src/Pricing.php:52]. Coupons are managed by an admin [src: app/controllers/admin.php].
Tax is off by default. When enabled you set its name and its rate, and the rate is held in basis points — so 20% is stored as 2000, which avoids rounding drift [src: app/src/Database.php:147].
Currency code and symbol are yours to set and default to USD and $ [src: app/src/Database.php:147].
Notifications and reminders
Slotly sends on four occasions: when a booking is confirmed, canceled or rescheduled, and as a reminder before the appointment [src: app/src/Notify.php:23].
E-mail is off until you enable it and supply your own SMTP details — host, port, user and encryption, defaulting to port 587 and tls [src: app/src/Database.php:147]. The sender name and address are separate settings [src: app/src/Database.php:147].
Reminders need a scheduled task. They are sent by cron/reminders.php, intended to run hourly [src: app/cron/reminders.php:3]. The reminder lead time defaults to 24 hours [src: app/src/Database.php:147]. Without that scheduled task the other three notifications still work and reminders simply never go out.
Links in emails and texts are full addresses. Slotly takes them from base_url in config.php when it is a full URL, otherwise from the address the admin was last opened at, which it records on signed-in pages [src: app/src/helpers.php].
Text reminders on your own SMS account
Text reminders are off until you connect an SMS gateway you already pay for, in Settings → Text reminders [src: app/src/Sms.php]. Slotly sells no credits and adds nothing to the price: your carrier bills you at its own rate.
- Any HTTP gateway. Slotly sends one HTTPS POST per text, as JSON or form-encoded, with your three field names for the number, the sender and the message (
to,from,textby default; Twilio's areTo,From,Body), and your key as a Bearer token, as HTTP Basic (user:password, for TwilioAccountSid:AuthToken) or not at all. Any 2xx answer counts as sent [src: app/src/Sms.php]. - Only customers who ask. The booking page shows "Text me reminders" (unticked) when texts are on. The customer's booking page has Stop text reminders [src: app/views/public/home.php, app/controllers/public.php].
- Two reminders. The first 24 hours before and the second 2 hours before by default; 0 switches either off. A booking made inside both windows gets only the nearer one, and moving a booking resets them [src: app/src/Sms.php, app/src/Schedule.php]. Texts go out from the same cron line as reminder emails; run it every 15 minutes if you use the 2-hour text.
- Test it. Send test in Settings sends one text and shows exactly what your gateway answered.
- The key is write-only in Settings and is left out of the shareable JSON backup [src: app/controllers/api.php].
Customers
Customers are records with a name and e-mail, created as bookings come in [src: app/controllers/public.php]. Staff may edit them; only an admin may delete one [src: app/controllers/api.php:30].
Categories (3.3). Give a customer a category (Regular, New, VIP, Corporate: whatever you use) on their record; the customer list filters by it, and it shows beside their name on every appointment [src: app/controllers/admin.php customer_categories].
Existing customers can be brought in from a CSV file at /admin/customers/import [src: app/index.php:157]. Columns are matched by header name rather than position, ignoring case, spaces and underscores and accepting common aliases [src: app/src/CsvImport.php].
Reports
The reports page is admin-only [src: app/controllers/admin.php]. A day-level summary is also available to an AI assistant as a tool — see below [src: app/controllers/mcp.php].
Settings reference
Settings are admin-only and are saved from one form [src: app/controllers/admin.php:621]. The defaults below come from the schema that creates the table [src: app/src/Database.php:147].
| Setting | Default |
|---|---|
| Business name | My Business |
| Business e-mail, phone, address | empty |
| Time zone | UTC |
| Currency, symbol | USD, $ |
| Time format | 12 hour |
| Week starts | Sunday (0) |
| Slot step | 15 minutes |
| Lead time | 120 minutes |
| Maximum advance | 60 days |
| Require approval | off |
| Allow cancellation, allow rescheduling | both on |
| Cancellation cutoff | 24 hours |
| Tax | off; name Tax, rate 0 basis points |
| Accent color | #0f766e |
| Reminder lead time | 24 hours |
| SMTP | off; port 587, encryption tls |
| Unpaid deposit hold released after | 24 hours |
| Deposit to confirm after N no-shows | 0 (off) |
| No-show fee | 0 (off) |
| Most appointments at the same time | 0 (no limit) |
| Cancellation policy | empty (no tick box) |
| Text reminders | off; JSON, Bearer key, fields to,from,text; 24 and 2 hours before |
| Card payments at booking | off; no keys, no currency; time to pay 30 minutes |
Free-text fields for the booking page — an introduction, a confirmation note and your terms — are set here as well [src: app/controllers/admin.php:621].
Backup and restore
An admin can export the data, and restoring is a two-step upload-then-confirm so you see what you are about to overwrite [src: app/src/BackupRestore.php]. Every booking also downloads as a spreadsheet at /admin/appointments.csv (administrators only, because it carries customer contact details): reference, start and end in your time zone and in UTC, service, staff, customer, party size, status and the money.
Unattended backups are fetched by your scheduler from /backup/scheduled?t=… [src: app/controllers/v3.php]. Two details matter: the token is compared against a stored hash, and a bad or missing token returns 404 rather than 401 — deliberate, since it makes the endpoint indistinguishable from a wrong URL to anyone probing [src: app/controllers/v3.php].
Backups are written to data/backups, created if absent [src: app/controllers/v3.php].
What the shareable JSON leaves out. Passwords and two-factor secrets, API-key and invite hashes, customers' manage-booking tokens, the SMTP password, the single sign-on secret, the backup token, webhook signing secrets, webhook URLs, the SMS gateway key and open waitlist offer links — every credential-named setting, matched by pattern [src: app/src/BackupExport.php, app/controllers/api.php]. After restoring it on a fresh install, re-enter your webhook URLs, as you do signing secrets. The raw SQLite file is the whole database, untouched.
The calendar feed
Appointments can be subscribed to at /feed/appointments.ics?t=… [src: app/controllers/v3.php]. When you create the feed URL under Calendar feeds you choose whose appointments it carries: the whole team's, or one staff member's own diary — so a stylist who subscribes sees their own bookings and nobody else's.
Slotly's feed tokens are better than a single shared secret: they live in their own table, several can exist, and any one of them can be revoked individually without disturbing the others [src: app/controllers/v3.php]. An unknown or revoked token returns 404 [src: app/controllers/v3.php].
The REST API
Six read-and-write endpoints sit under /api/v1/: appointments as a list and singly, customers, services and slots [src: app/controllers/api.php]. A machine-readable description is published at /api/openapi.json [src: app/controllers/api.php].
Authentication is a bearer key of the form apk_ followed by forty hexadecimal characters [src: app/src/Api.php:32]. Keys are checked against a stored hash, must belong to an active user, and must not be revoked; each use updates a last-used timestamp so you can spot a key nobody is using [src: app/src/Api.php:35].
A read-only key is refused at the door. The scope is enforced in the shared entry function rather than in each route, on the stated reasoning that the route which forgets is exactly the one that would leak [src: app/src/Api.php:44].
Webhooks
Slotly sends five events, the five offered in Settings [src: app/src/Webhook.php]: appointment.created (the public booking page, the admin's own form, the REST API and the AI assistant), appointment.rescheduled (the customer's own link, the admin calendar and the assistant), appointment.cancelled (the customer's own link, staff setting the status, or a deposit hold that lapsed, with "by": "hold_lapsed"), payment.recorded (a payment recorded against a booking) and appointment.confirmed (a deposit hold was paid and the booking now counts).
Connecting an AI assistant
Slotly speaks the Model Context Protocol at /mcp, with a probe on GET and the endpoint on POST [src: app/controllers/mcp.php].
Eight tools are exposed: list_services, list_staff, availability, list_appointments, appointment_detail, book_appointment, reschedule_appointment and day_report [src: app/controllers/mcp.php].
The assistant's rights come from the API key it is given, and the refusal vocabulary is shared with the admin interface, so an assistant is told the same thing a person would be [src: app/controllers/api.php:37].
The audit log
The log is readable in the admin area and exportable as CSV at /admin/audit.csv [src: app/controllers/v3.php]. As noted under Roles and permissions, every role can read it.
Data-protection tools
A customer's held data can be exported as JSON from /admin/customers/{id}/gdpr.json [src: app/controllers/v3.php]. Slotly records the request; whether your response satisfies the law where you trade is a matter for you and your adviser.
Looking after your install (from 4.0)
Updates from inside Slotly
Updates in the menu opens the Updates page (/admin/updates) [src: app/views/admin/updates.php]. Paste your license key once and press Save key: either key works, the one in your Lemon Squeezy receipt or the one on your order page (it starts with OWX-). Check for updates asks the store for the newest release your license covers; Slotly never checks by itself.
When a new version is ready, Back up and update to that version backs up your data and the current version, installs the new one and checks it. If the update is interrupted, or the new version's database step or that check fails, the previous version is put back by itself. Slotly is unavailable for about a minute while this runs. The update replaces Slotly's files, including any you edited, except .htaccess, .user.ini and robots.txt: an edited copy of those is kept, with the new version's copy beside it as .new to compare and merge. The Health page lists any file that differs from the version installed, so you can see that before you update. A copy of the previous version (data/updates/code-<version>/) and a backup of your data from just before the update stay in data/updates/ [src: app/src/Updater.php]. If Slotly shows an error page after an update anyway, that is your way back: copy the files from code-<version>/ over the app's files, then restore the backup on the backup page. Updating from the page needs PHP's sodium and Phar extensions; if one is missing, the Updates and Health pages say so and updates stay by hand. Where Slotly is hosted for you, the host updates it and the page says so.
Reaching 4.0 from a 3.x version is the last update done by hand: replace the files as before (config.php and data/ stay, and the database migrates itself on the next page load). From 4.0 on, the Updates page does it.
Starting with sample data
The installer offers Start with sample data: a beauty studio's services, staff hours, customers and a month of appointments to try things on. You can remove it in one click. [src: app/install/index.php:111]. It is ticked by default; untick it to start empty. The sample is added when you open the first page after the installer.
When you are done with it, press the remove button in the sample banner. It asks "Remove the sample services, customers and appointments? Your own records stay." Only the sample goes: your own records stay, and a sample record that one of your own records uses is kept and named in the report [src: app/src/SampleData.php].
Backups by themselves, and a copy somewhere else
On the Backups page (/admin/restore), Back up once a day by itself is on by default; untick it to stop [src: app/views/admin/restore.php:61]. The first visit at least 24 hours after the last backup starts the next one, after the page has loaded, so no cron job is needed. The newest 14 stay in data/backups/, with credentials left out of them (a restore keeps the live values for those). Back up now takes one straight away.
To keep a copy somewhere else as well, fill in an S3-compatible bucket (Backblaze B2, Wasabi, AWS S3 or your own MinIO): the bucket address (an https endpoint), the region, the bucket name, an optional folder, and the access key ID and secret key. Leave the address empty to keep backups on this server only. The copy goes only to the bucket you set; nothing is sent to Ownware. Once a week Slotly reads the newest backup back through the restore's own dry run and keeps the result for the Health page [src: app/src/BackupTargets.php].
On Nginx: keep data/ private
On Apache, the .htaccess that ships with Slotly keeps data/ (your backups included), src/, config.php and the other private files off the web. Nginx does not read .htaccess, so add these three blocks to your server block, above the block that passes .php files to PHP (Nginx uses the first one that matches):
location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
location ~ ^/views/.+\.php$ { deny all; }
location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }
Then open /config.php in a browser: it should answer 403. Only the template files under views/ are closed, not the folder: the app's own saved-view addresses start with /views/ too, and they must reach it.
A complete server block for a new site (PHP-FPM 8.3; change the name, the folder and the socket to yours):
server {
listen 80;
server_name example.com;
root /var/www/slotly/app;
index index.php;
location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
location ~ ^/views/.+\.php$ { deny all; }
location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }
location = /install/ { rewrite ^ /install/index.php last; }
location / { try_files $uri /index.php?$query_string; }
location ~ \.php$ {
try_files $uri =404;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}
Behind Cloudflare or a load balancer: set trusted_proxies
Slotly counts its limits per visitor address: 30 bookings in 10 minutes on the booking page [src: app/controllers/public.php:341], and 10 tries to join the waitlist or 10 failed sign-ins in 10 minutes [src: app/src/LoginRate.php:20]. When Cloudflare, a load balancer or another proxy sits in front of Slotly, every request reaches Slotly from the proxy's address, and the visitor's own address comes in a header (CF-Connecting-IP from Cloudflare, X-Forwarded-For from most others). Anyone can type such a header, so Slotly believes it only when the request comes from a proxy listed in trusted_proxies [src: app/src/helpers.php:224]. Out of the box that list holds only this machine and the private network ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 and their IPv6 counterparts) [src: app/src/helpers.php:195], which covers a proxy on the same server or the same private network. Cloudflare and most hosted load balancers use public addresses, so they are not on it. Then every customer counts as the proxy: they all share one limit of 30 bookings in 10 minutes, so on a busy day a real customer can be told "Too many requests", and the audit log records the proxy's address instead of the customer's.
How to set it. Open config.php in the app folder (the installer wrote it) and add the trusted_proxies entry below to the settings it returns, for example on the line after 'base_url' => '',. Behind Cloudflare, the list is Cloudflare's published address ranges:
'trusted_proxies' => [
// Cloudflare's ranges, from https://www.cloudflare.com/ips/ (as published on 2026-10-12)
'173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22',
'141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20',
'197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13',
'104.24.0.0/14', '172.64.0.0/13', '131.0.72.0/22',
'2400:cb00::/32', '2606:4700::/32', '2803:f800::/32', '2405:b500::/32',
'2405:8100::/32', '2a06:98c0::/29', '2c0f:f248::/32',
// Keep these only if a proxy on this server or your private network (nginx, Caddy, Traefik)
// also passes requests on to Slotly:
'127.0.0.0/8', '::1/128', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', 'fc00::/7',
],
- Your list replaces the built-in one. Keep the private ranges at the end only if a proxy on this server or your network also passes requests on to Slotly; remove them if nothing does.
- Cloudflare changes its list now and then: compare it with https://www.cloudflare.com/ips/ once or twice a year.
- Behind another proxy or a load balancer, list its address or range instead; your hosting provider publishes it. One address such as
'203.0.113.10'works as well as a range [src: app/src/helpers.php:206]. - A comma-separated string works too:
'trusted_proxies' => '173.245.48.0/20, 103.21.244.0/22'[src: app/src/helpers.php:190].
The Health page
Health in the menu (/admin/health) shows, one line each, whether the install is looking after itself: the PHP version and extensions, disk space, whether data/ is writable, email, the age of the last backup, the offsite copy, the weekly check, whether an update is waiting, and any file that differs from the version installed. Each line says what to do when it is not fine. The page only reads: it never changes anything and never contacts anything [src: app/src/Health.php].
Switching from Calendly: the people who booked, as customers
On the customers Import page in the admin (/admin/customers/import), Switch from another tool takes the file Calendly exports. In Calendly: Calendar > Filter > Meeting status: Scheduled and Canceled > Apply > Export (the download icon beside Filter). Upload it and press Read the export (nothing is written yet): the switch report shows how many rows will import, which are already here from an earlier import, which repeat a row above, which have a problem (by line), and which columns do not come across. Then import them in one click. Importing the same file again adds nothing twice [src: app/views/_switch.php].
Health check
/healthz returns the application name, the version and an ok flag, and needs no sign-in, so an uptime monitor can poll it [src: app/controllers/api.php:80].
Where things live
| Path | Contents |
|---|---|
app/index.php | the front controller, the version constant and most routes [src: app/index.php:7] |
app/controllers/public.php | the customer-facing booking flow [src: app/controllers/public.php:140] |
app/controllers/admin.php | the admin area [src: app/controllers/admin.php:621] |
app/controllers/api.php | the REST API, and the permission map [src: app/controllers/api.php:27] |
app/controllers/mcp.php | the AI-assistant endpoint [src: app/controllers/mcp.php] |
app/src/Schedule.php | availability, validation, booking and moving [src: app/src/Schedule.php:52] |
app/src/Pricing.php | quotes and coupons [src: app/src/Pricing.php:10] |
app/src/Notify.php | the four notification occasions [src: app/src/Notify.php:23] |
app/cron/reminders.php | the hourly reminder task [src: app/cron/reminders.php:3] |
app/tests/run.php | the test suite [src: app/tests/run.php] |
Connect it to other tools (Zapier, Make, n8n)
Slotly sends a webhook the moment something happens and accepts calls to its API, so Zapier, Make and n8n can connect to it without a plugin. The steps, the events and example recipes are in API.md, in the section "Connect it to Zapier, Make or n8n"; the API page of Slotly on the store shows the same file [src: app/API.md].
New in 4.1.2: sign-in limits
New in 4.1.2 (security update): Two-step codes are now limited (10 wrong per person or per address in 10 minutes), and wrong passwords count per account as well as per address. An installed app whose database does not answer keeps its web installer closed instead of showing the install form. Turning two-step sign-in off asks for a current code. On Ownware Cloud, opening the app from the console starts a fresh session.
Wrong passwords are counted per address and, since 4.1.2, per account too (the email typed, in any case, stored only as a hash). After 10 wrong passwords in 10 minutes the sign-in at /admin/login answers "Too many attempts — wait a few minutes and try again." for that address, and for that account from any address. A right password clears both counts [src: app/src/LoginRate.php:40].
Wrong two-step codes (for people who turned two-step sign-in on under /admin/security) are counted per person and per address. After 10 wrong codes in 10 minutes the code step answers "Too many wrong codes. Wait ten minutes, then sign in again. A recovery code works in place of the code." even for the right code, and the half-finished sign-in is dropped, so the password is asked again [src: app/src/LoginRate.php:91]. The right code clears that person's count; an address's count runs out ten minutes after its wrong codes. Before 4.1.2 the code step had no limit.
If you are locked out: wait ten minutes, then sign in again with your password and a current code, or one of your recovery codes (each works once). If you lost both the authenticator and the recovery codes, whoever runs the server can turn two-step sign-in off for you in Slotly's database (by default the SQLite file in data/, or your MySQL database): UPDATE users SET totp_secret = '' WHERE email = 'you@example.com'; Then sign in with your password and turn it on again under /admin/security.
Turning two-step sign-in off under /admin/security now asks for a current code, as in the other apps; before 4.1.2 the signed-in session alone was enough [src: app/controllers/api.php:149].
On Ownware Cloud, opening Slotly from the console starts a fresh session [src: app/index.php:70].
The web installer stays closed when the database does not answer: with config.php in place, /install now answers 503 "This app is already installed (config.php exists), but its database is not answering, so the installer stays closed." and writes nothing until the database answers again. Before 4.1.2 a database outage (or a missing SQLite file) showed the install form there, and whoever found it could set up a new administrator. To install again from scratch, remove config.php first [src: app/install/index.php:21].
New in 4.1.3: security update
New in 4.1.3 (security update): no other site can put Slotly's pages in a frame; the /mcp address refuses requests from other sites' pages; company sign-in is stricter; the audit CSV treats one more first character as text; and the leftover seed.php page is gone. Nothing else changed: no feature, screen, setting or database table, and your data, config.php and data/ are not touched.
No other site can frame these pages. Every page now answers Content-Security-Policy: frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN, so a page on another site cannot show Slotly in a hidden frame and trick a signed-in person into pressing a button [src: app/index.php:4]. The installer answers frame-ancestors 'none' and X-Frame-Options: DENY [src: app/install/index.php:4]. If you had Slotly framed inside another site of your own, that frame now stays empty; link to it instead.
/mcp refuses other sites. A request whose Origin header names another site gets 403 before anything is read. A desktop or server client sends no Origin and is served as before, and so is a page at Slotly's own address [src: app/src/Mcp.php:272].
Single sign-on. The sign-in now uses PKCE (S256), so an intercepted sign-in code is of no use on its own; a sign-in that comes back to a different browser session is asked to start again [src: app/src/Oidc.php:228]. With Google as the provider, a company address needs a Google Workspace account of that same domain (the token's hd claim), and Google must report the address as verified, so a personal Google account made with a work address no longer gets in [src: app/src/Oidc.php:215]. Gmail addresses work as before, other providers are unchanged, and password sign-in is not affected.
The audit CSV. A cell that starts with a carriage return now opens in a spreadsheet as text, as cells starting with = + - @ or TAB already did [src: app/src/AuditLog.php:138].
Docker. The Dockerfile now installs PHP's zip extension, so a Docker install can make the backup with files [src: app/Dockerfile:9].
The leftover seed.php page is gone. It came from before the installer offered sample data. Anyone who asked for it, signed in or not, could replace all of an install's data with sample data and lock out its owner. It is no longer in the zip, updating removes it from your server, and .htaccess refuses the name in case a copy stays behind [src: app/.htaccess:29]. If you replace files by hand instead of updating, delete seed.php yourself. If you run Nginx or another server, deleting the file is the whole fix.
Also changed, with no effect on a running install: the corner card on a public demo offers only the purchase, and API.md no longer shows the config example of a product we retired.