Contents

Webhooks - Plugixa Car Wash

Webhooks PRO tell another system what happens in your car wash: a CRM, an accounting tool, Zapier or your own code. Each event is sent as a signed JSON POST moments after it happens, retried for about nine hours if the other end is down, and logged for 30 days.

Open Plugixa Car Wash -> Webhooks. The entry is in the Configuration group of the menu.

The Webhooks page with two endpoints, Accounting and CRM sync, their events and weekly counts, and the Checking a request is genuine card

Each endpoint in the list shows its name and address, a dot for its last delivery (green when it succeeded, red when it failed, grey before anything was sent), the events it receives, and how many deliveries succeeded and failed in the last seven days. A Paused chip marks a paused endpoint, and a count of waiting deliveries appears while any are queued.

Who can manage webhooks

Only a user with the plugixa_carwash_manage_settings capability sees the Webhooks entry, and the webhook routes refuse everybody else. A webhook sends customer data to an address of your choice and its page shows the signing secret, so it is kept to people who manage settings. See Roles and permissions.

If the entry is missing for an administrator, the Webhooks app is switched off in the App Manager.

Adding an endpoint

  1. Click Add an endpoint.
  2. Fill in the form and click Save.
Field Notes
Endpoint URL “Where each event is POSTed as JSON. It must be reachable from the internet.” A full http:// or https:// address. Anything else is refused with “Enter the full address of the endpoint, starting with https://.”
Name For you, for example CRM, Zapier or accounting. Left empty, the endpoint is named after the address’s host.
Events to send Tick the events this system wants, or Every event, including ones added later. At least one is required: “Choose at least one event to send.”
Active On: events are sent as they happen. Off: the endpoint is paused.

The endpoint is saved with a new signing secret, and its page opens.

The CRM sync endpoint page with Send test, Edit and Delete, the hidden signing secret, its three events and one delivered webhook.test in the Deliveries table

On the endpoint’s page:

Control What it does
Send test Sends a webhook.test event now and reports the answer: “Test delivered: the endpoint answered 200 in 84 ms.” or “Test failed:” with the reason. A paused endpoint can still be tested.
Edit Change the address, name, events or the Active switch.
Delete Deletes the endpoint and its whole delivery log. To stop sending for a while, pause it instead.
Signing secret Hidden until you click the eye. The copy button puts it on your clipboard.
Make a new secret Replaces the secret. The old one stops working at once, so the receiving system rejects requests until it has the new one.

Paused endpoints

Nothing is queued for a paused endpoint. An event that happens while it is paused is not kept for later: switching it back on sends only what happens from then on.

Events

Event Sent when data holds
booking.created A booking was made, online or by staff. booking, and source: online or staff
booking.status_changed A booking was confirmed, started, completed, cancelled or marked a no-show. booking after the change, from and to
booking.moved A booking moved to another time, bay or team. booking after the move, and before with its old start_at, end_at and resource_id
payment.recorded A payment or refund was recorded against a booking. payment (id, booking_id, provider, amount, currency, status) and the booking after it
customer.created A new customer was added, by staff or by a first booking. customer: the customer record
job.stage_changed A mobile job moved on: on the way, arrived, washing, done. Needs Dispatch. stage (en_route, arrived, working or done), job (booking_id, stage, en_route_at, arrived_at) and booking
fleet.statement_issued A company account was sent its statement. Needs Company accounts. statement, and account (id, name, contact_name, email, phone, tax_id)
webhook.test You clicked Send test. It cannot be subscribed to. message

A refund in payment.recorded has a negative amount and the status refunded. A Stripe payment is recorded once, when Stripe confirms it, with the provider stripe. A refund made in Stripe’s own dashboard is recorded too, and sends its own payment.recorded.

A booking.status_changed is also sent when the plugin changes a status by itself, for example when an online payment hold runs out and the booking is cancelled.

What is sent

Every request is a POST with a JSON body of this shape:

Key Holds
id The event’s id, a UUID. The same on every retry and resend of this delivery.
event The event name.
created_at When the event was queued, in UTC, ISO 8601.
data The event’s data, as in the table above.

A booking is the booking as the staff screens see it, minus four things: the private link token that lets its holder cancel the booking, the staff notes, the status history, and the ids of the staff who created and changed it. Times such as start_at are in UTC; local_date and local_time are the start at the booking’s location. Money amounts are numbers in the booking’s currency.

A booking.created delivery looks like this:

{
  "id": "3f2c8a1e-6b0d-4e5f-9a7b-2c1d0e9f8a76",
  "event": "booking.created",
  "created_at": "2026-10-23T09:15:02+00:00",
  "data": {
    "booking": {
      "id": 42,
      "number": "CW2026-0042",
      "form_id": 1,
      "location_id": 1,
      "resource_id": 2,
      "customer_id": 17,
      "customer_vehicle_id": 21,
      "vehicle_type_id": 3,
      "package_id": 2,
      "start_at": "2026-10-24 09:00:00",
      "end_at": "2026-10-24 09:45:00",
      "blocked_until": "2026-10-24 09:50:00",
      "duration": 45,
      "status": "confirmed",
      "hold_expires_at": null,
      "first_name": "Olivia",
      "last_name": "Bennett",
      "email": "olivia@example.com",
      "phone": "07700 900123",
      "company": "",
      "vehicle_make": "Toyota Corolla",
      "vehicle_plate": "AB12 CDE",
      "address": "",
      "message": "",
      "agreements": [
        { "text": "I accept the terms.", "accepted": true }
      ],
      "currency": "GBP",
      "subtotal": 50.0,
      "discount": 0.0,
      "coupon_code": "",
      "tax": 0.0,
      "total": 50.0,
      "payment_method": "cash",
      "payment_status": "unpaid",
      "source": "online",
      "page_url": "https://example.com/book/",
      "created_at": "2026-10-23 09:15:01",
      "modified_at": "2026-10-23 09:15:01",
      "items": [
        { "role": "package", "item_id": 2, "name": "Deluxe Wash", "price": 40.0, "duration": 30 },
        { "role": "addon", "item_id": 5, "name": "Hand wax", "price": 10.0, "duration": 15 }
      ],
      "location": { "id": 1, "name": "Harbour Street Car Wash" },
      "local_date": "2026-10-24",
      "local_time": "10:00",
      "resource": { "id": 2, "name": "Bay 2", "color": "#1976d2" },
      "payments": []
    },
    "source": "online"
  }
}

A booking made by staff has form_id null and an empty page_url. The payments list has one entry per payment with id, provider, amount, currency, status, reference, note, created_at and paid_at.

Headers

Header Value
Content-Type application/json; charset=utf-8
User-Agent PlugixaCarWash-Webhooks/1.0.0, with the plugin’s version
X-PCW-Event The event name
X-PCW-Delivery The event’s id, the same as id in the body
X-PCW-Signature t=<unix time>,v1=<signature>

Checking a request is genuine

Anybody can send a POST to your endpoint. The signature proves a request came from your site and was not changed on the way.

v1 is the HMAC-SHA256, in lowercase hex, of the text <t>.<raw body>: the t value from the header, a full stop, and the request body exactly as it arrived, keyed with the endpoint’s signing secret. To check a request:

  1. Read t and v1 from X-PCW-Signature.
  2. Compute the HMAC-SHA256 of t + "." + raw body with your secret.
  3. Compare it with v1 in constant time. Refuse the request if they differ.
  4. Refuse it if t is more than a few minutes from your clock, so an old request replayed at you is not accepted.
  5. Keep the X-PCW-Delivery ids you have handled, and answer 200 without acting again when one comes back. Retries and resends reuse the id.

Sign the raw body, before any JSON parsing: re-encoding the JSON changes the bytes and the signature no longer matches. Each attempt is signed afresh, so a retry carries a new t.

PHP:

$secret = 'whsec_...'; // The endpoint's signing secret.
$body   = file_get_contents( 'php://input' );
$header = $_SERVER['HTTP_X_PCW_SIGNATURE'] ?? '';

if ( ! preg_match( '/^t=(\d+),v1=([a-f0-9]{64})$/', $header, $m ) ) {
	http_response_code( 400 );
	exit;
}

$expected = hash_hmac( 'sha256', $m[1] . '.' . $body, $secret );

if ( ! hash_equals( $expected, $m[2] ) || abs( time() - (int) $m[1] ) > 300 ) {
	http_response_code( 400 );
	exit;
}

$event = json_decode( $body, true );
// $event['event'], $event['data'] ...
http_response_code( 200 );

Node.js with Express:

const crypto = require('crypto');
const express = require('express');

const app = express();
const SECRET = process.env.PCW_WEBHOOK_SECRET; // The endpoint's signing secret.

// express.raw keeps the body as the exact bytes that were signed.
app.post('/carwash', express.raw({ type: 'application/json' }), (req, res) => {
  const match = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(req.get('X-PCW-Signature') || '');
  if (!match) return res.sendStatus(400);

  const [, t, v1] = match;
  const expected = crypto.createHmac('sha256', SECRET).update(`${t}.`).update(req.body).digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - Number(t)) <= 300;

  if (!fresh || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) {
    return res.sendStatus(400);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // event.event, event.data ...
  res.sendStatus(200);
});

The Webhooks page shows the same instructions under Checking a request is genuine, with a short PHP version.

Delivery and retries

Nothing is sent while the customer or the staff member waits. The event is written to the delivery log during the request that caused it, and a scheduled job sends it moments later, on WordPress’s scheduler. A slow or dead endpoint can never slow down or fail a booking.

Your endpoint must answer with a 2xx status within 10 seconds. Anything else counts as a failure: another status, a timeout, a connection error, or a redirect, which is not followed so the signed body never goes to an address you did not enter.

A failed delivery is retried with growing gaps:

Attempt When
1 Straight away
2 1 minute after attempt 1
3 5 minutes after attempt 2
4 30 minutes after attempt 3
5 2 hours after attempt 4
6 6 hours after attempt 5

Six attempts over about nine hours. When the sixth fails, the delivery is marked Failed and stays in the log to be sent again by hand.

Deliveries are not guaranteed to arrive in order. A delivery that is being retried can arrive after a later event. Use created_at in the body to order them.

Each run of the scheduled job sends up to 25 deliveries that are due, and books another run when more are waiting. An hourly run picks up anything a missed run left behind. A delivery left at Sending for more than 10 minutes by a request that died is put back in the queue by the next run.

The scheduled job runs on WP-Cron, which only fires when somebody visits the site. On a quiet site, deliveries and retries can be late. See Reminders and text messages for a real server cron.

The delivery log

The endpoint’s page lists its last 50 deliveries, newest first, with the event, the status, the HTTP response and how long it took, the number of attempts and when. Deliveries are kept for 30 days.

Status Meaning
Queued Waiting for its first attempt.
Sending Being sent now.
Will retry An attempt failed; another is scheduled.
Delivered The endpoint answered 2xx.
Failed Every attempt failed, or a test or resend failed.

Click a delivery to see the event id, the attempts and the next try, the body that was sent, and the endpoint’s answer (its first 1,000 characters).

Send again

Send again in that window sends the same body, with the same event id, now. It works on a delivered or failed delivery as well as a waiting one. Only a delivery that is being sent at that moment is refused, with “That delivery is being sent right now.”

A resend is a single attempt with no retries. If you click Send again on a delivery that is still waiting for a retry and the resend fails, the delivery is marked Failed and its remaining automatic retries are cancelled. Let a retrying delivery run its course, or be ready to send it again by hand.

A test send works the same way: one attempt, logged, no retries.

Addresses that are refused

Requests are sent with WordPress’s safe HTTP functions. An address that leads to the server itself or to a private network is refused when sending, so a webhook cannot be used to reach what the site can see and the internet cannot: localhost, 127.0.0.1, and the private ranges such as 10.x.x.x, 172.16.x.x to 172.31.x.x and 192.168.x.x. WordPress also only allows the ports 80, 443 and 8080 for these requests.

The address is checked each time a delivery is sent, not when you save it, so an address that later points somewhere private is still caught. The delivery fails with “This address points at a private or local network, which webhooks may not reach.” To test against a receiver on your own computer, expose it with a public tunnel address.

Without Pro

The free edition sends no webhooks and has no Webhooks page. Your endpoints and their log stay in the database if Pro is removed. Developers can react to the same events in PHP with the plugin’s actions: see Hooks.

Quick Links