Contents
REST API - Plugixa Car Wash
Everything the staff app, the booking form and the customer account page do,
they do over the WordPress REST API, under the namespace plugixa-carwash/v1.
Every rule is enforced at the route, not in the page, so a request made by hand
is checked exactly like one made by the app.
https://example.com/wp-json/plugixa-carwash/v1/
On a site with plain permalinks the address is
https://example.com/?rest_route=/plugixa-carwash/v1/ instead.
This is the API the plugin’s own screens use. It is not a separately versioned public API, and routes, parameters and answers can change between versions. Pin the plugin version you build against, and test after updating.
Three kinds of caller
| Caller | Routes | How it is identified |
|---|---|---|
| Staff | Everything outside /public/ |
A WordPress user, checked against the plugin’s capabilities |
| Visitors | /public/... |
Nobody: the routes are open, and protected by rate limits instead |
| Customers | /public/account/... |
A session from the passwordless sign-in, sent in the X-PCW-Session header |
Staff
The plugin adds no login of its own to the staff routes. It relies on what WordPress provides, then checks the capabilities of the user WordPress identified.
| Caller | How |
|---|---|
| The staff app, in wp-admin or on its private page | The logged-in cookie and a REST nonce in the X-WP-Nonce header, the normal WordPress way |
| A script or another system | Any method WordPress accepts for REST requests, such as an application password for a user who holds the capabilities below |
curl -u "manager:abcd efgh ijkl mnop qrst uvwx" \
"https://example.com/wp-json/plugixa-carwash/v1/bookings?per_page=20"
Customers
A customer is not a WordPress user. The account page signs them in by email:
POST /public/account/linkwithemail(and thepage_urlto return to) emails a one-time sign-in link, valid for 30 minutes. The answer is the same whether or not the address belongs to a customer.POST /public/account/sign-inwith thetokenfrom that link answers with asessionand itsexpires_at. The link cannot be used twice.- Every other account route reads the session from the
X-PCW-Sessionheader. A session lasts 30 days. Without a valid one the answer is 401 with the codeplugixa_carwash_signed_out.
The session travels in a header, never a cookie, so another site cannot make the browser use it. Only a hash of each token is stored.
Capabilities
| Capability | What it allows |
|---|---|
plugixa_carwash_view |
Read records: lists, detail screens, the schedule, the dashboard figures |
plugixa_carwash_create |
Create records, and look up free times for a staff booking |
plugixa_carwash_edit |
Change records, change a booking’s status, move it, record a payment or refund |
plugixa_carwash_delete |
Move records to the trash, restore them, delete them for good |
plugixa_carwash_export |
Export the bookings and customers lists as CSV, and read reports PRO |
plugixa_carwash_manage_settings |
Change the settings, and manage Stripe, text messages, travel fees, dispatch checklists, webhooks and the calendar feed PRO |
plugixa_carwash_manage |
Open the app. Never granted directly: anyone who holds one of the six above has it. |
Activation gives all of them to the Administrator role. A user with the
WordPress manage_options capability is always treated as holding every one.
To give them to staff, see
Roles and permissions.
A few things need manage_options itself, because they decide what the plugin
is rather than how it is used: the App Manager
(/apps), the role screens PRO, and switching on Delete all car
wash data when the plugin is deleted. A settings manager without
manage_options can save every other setting.
A refusal has the code plugixa_carwash_forbidden and the message “You are not
allowed to do that.”, with status 401 when not signed in and 403 when signed in.
Every check passes through the plugixa_carwash_user_can filter. See
Hooks.
Staff routes
Records
Each of these resources has the same seven routes:
| Method and path | Needs |
|---|---|
GET /{resource} |
view |
POST /{resource} |
create |
GET /{resource}/{id} |
view |
PUT or PATCH /{resource}/{id} |
edit |
DELETE /{resource}/{id} (to the trash) |
delete |
POST /{resource}/{id}/restore |
delete |
DELETE /{resource}/{id}/purge (for good) |
delete |
| Resource | Records |
|---|---|
bookings |
Bookings |
customers |
Customers |
locations |
Locations, with their hours and closures |
resources |
Bays and mobile teams |
vehicle-types |
Vehicle types |
packages |
Packages |
services |
Services and add-ons |
coupons |
Coupons |
booking-forms |
Booking forms |
membership-plans PRO |
Membership plans |
memberships PRO |
Customers’ memberships |
gift-cards PRO |
Gift cards |
fleet-accounts PRO |
Company accounts |
roles PRO |
Staff roles (needs manage_options) |
Lists take page, per_page (1 to 200, default 50), search, orderby and
order (asc or desc), plus filters. GET /filters/{entity} describes the
filters a list accepts.
An update may include the record’s modified_at as you last read it. If somebody
saved the record since, the update is refused with status 409 instead of
overwriting their change. Without modified_at, the update goes ahead.
Bookings
| Method and path | Needs | Does |
|---|---|---|
POST /bookings/{id}/status |
edit | Change the status. Body: status, optional note. |
POST /bookings/{id}/move |
edit | Move to another time, bay or team. Body: date (Y-m-d), time (H:i), optional resource_id. |
GET /bookings/slots |
create | Free start times for a staff booking. |
GET /bookings/day |
view | One location’s day: location_id, date. |
GET /bookings/range |
view | Several days: location_id, from, days (default 7). |
GET /bookings/stats |
view | The dashboard figures over days (default 14). |
GET /bookings/{id}/payments |
view | The booking’s payments. |
POST /bookings/{id}/payments |
edit | Record a payment, or a refund with a negative amount. Body: amount, provider, optional note. A refund larger than what has been paid, less earlier refunds, is refused with plugixa_carwash_refund_too_large (422). |
GET /payments/methods |
view | The payment methods and which are switched on. |
GET /customers/{id}/bookings |
view | A customer’s bookings. |
POST /coupons/generate |
create | Generate coupon codes. |
Everything else
| Method and path | Needs |
|---|---|
GET /bootstrap, GET /meta/entities, GET /users |
view |
GET /settings |
view |
POST /settings |
manage settings |
POST /mass-action/{entity} |
edit, or delete when action is delete. Body: action (update or delete), ids, and values for an update. |
GET and POST /apps |
manage_options |
GET /export/{entity} |
view and export on that entity. entity is booking or customer. Takes the list’s own search, filter and sort parameters, plus columns (comma-separated grid fields). Answers a CSV file. |
GET /reports PRO |
export. Query: from, to, location_id. |
/webhooks, /webhook-deliveries/{id}/resend PRO |
manage settings |
/sms/settings, /sms/test PRO |
manage settings |
/stripe/settings, /mobile/settings, /dispatch/settings, /calendar-feed/settings PRO |
manage settings |
/dispatch, /bookings/{id}/job/... PRO |
view to read, edit to change |
/customers/{id}/loyalty PRO |
view customers to read, edit customers to adjust or redeem |
/user-roles PRO |
manage_options |
Public routes
Public routes are open to everybody, because the booking form and the account page run for people who are not signed in to WordPress. Instead of a login, every public route except sign-out runs three checks first:
- A honeypot on the booking and sign-in-link routes: a hidden field,
website, that a person leaves empty and a simple bot fills in. A filled-in field is refused with status 400 and the codeplugixa_carwash_rejected. - A rate limit per visitor address and per kind of request, below.
- A CAPTCHA seam: the
plugixa_carwash_public_request_captchafilter. No CAPTCHA is included, so nothing is checked until you hook it. See Hooks.
| Method and path | Bucket | Limit per hour | Notes |
|---|---|---|---|
GET /public/forms/{id} |
carwash_read |
600 | What a form sells, priced per vehicle |
GET /public/quote |
carwash_read |
600 | The live price summary |
GET /public/calendar |
carwash_read |
600 | Which dates have a free time |
GET /public/slots |
carwash_read |
600 | The start times on one date |
GET /public/bookings/{token} |
carwash_read |
600 | The customer’s own booking, by its 32-character token |
POST /public/bookings |
carwash_book |
10 | Make a booking. Honeypot website. |
POST /public/bookings/{token}/cancel |
carwash_cancel |
10 | Cancel, until the cut-off in Settings -> Bookings |
POST /public/account/link |
carwash_account_link |
5 | Email a sign-in link. Honeypot website. |
POST /public/account/sign-in |
carwash_account_sign_in |
20 | Exchange the link’s token for a session |
GET /public/account |
carwash_account |
300 | The signed-in customer’s bookings and details |
POST /public/account/profile |
carwash_account |
300 | Update their details |
POST /public/account/panels/{panel}/{action} |
carwash_account |
300 | A button on an account panel |
POST /public/account/sign-out |
none | none | End the session |
The read routes, the quote and the slots must name a form_id. Without one they
answer 404 “This booking form is not available.”, so a visitor can never see the
prices and times that only staff may use.
The server never takes the browser’s word for a price or a free time. Every booking is priced again, checked against what its form offers, and given a bay under a database lock, so two customers cannot take the same bay.
Every public answer is sent with Cache-Control: no-store, and account answers
with no-store, private, so a page cache never serves one visitor’s times or
details to another.
How the rate limit counts
Each visitor address has its own counter for each bucket, kept in a WordPress
transient under a hash of the address. When the limit is reached, requests in
that bucket are refused with status 429, the code plugixa_carwash_rate_limited
and “Too many requests. Please wait a little while and try again.” The counter
clears an hour after the last request that was counted.
The address is REMOTE_ADDR. Behind a proxy or a CDN every visitor can share the
proxy’s address, and so one counter: tell the plugin the real address with the
plugixa_carwash_client_ip filter. See
Hooks.
Pro public routes PRO
| Method and path | Protected by |
|---|---|
GET /calendar-feed/{token}.ics |
The 32-character secret in the address. See Calendar feed. |
POST /stripe/webhook |
Stripe’s signature, checked against your webhook signing secret. See Stripe. |
GET /pwa/manifest, GET /pwa/sw.js |
Nothing private in them. They exist only while Allow installing is on. See Dark mode and staff app. |
Request and response shape
Send request bodies as JSON with Content-Type: application/json.
A successful answer wraps its payload:
{
"success": true,
"data": { "id": 42, "number": "CW2026-0042", "status": "confirmed" }
}
A list adds meta:
{
"success": true,
"data": [],
"meta": { "total": 42, "pages": 1 }
}
A refusal is a standard WordPress REST error, with a code, a readable
message and the HTTP status in data:
{
"code": "plugixa_carwash_slot_taken",
"message": "Sorry, that time was just taken. Please choose another.",
"data": { "status": 409 }
}
Some refusals add the field the message is about, so a form can point at it.
The .ics feed, the manifest and the service worker answer with their own
formats, not JSON.
Times sent and received as start_at, end_at and similar are in UTC
(Y-m-d H:i:s). The date and time you send when booking or moving are read
in the location’s own time zone.