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:

  1. POST /public/account/link with email (and the page_url to 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.
  2. POST /public/account/sign-in with the token from that link answers with a session and its expires_at. The link cannot be used twice.
  3. Every other account route reads the session from the X-PCW-Session header. A session lasts 30 days. Without a valid one the answer is 401 with the code plugixa_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:

  1. 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 code plugixa_carwash_rejected.
  2. A rate limit per visitor address and per kind of request, below.
  3. A CAPTCHA seam: the plugixa_carwash_public_request_captcha filter. 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.

Quick Links