Guides
Webhooks
Instead of asking again and again whether a booking changed, we send your server a signed message the moment it does.
Why webhooks
A booking is paid, collected, returned or cancelled while you are not looking. A webhook tells you at once, so you can mark an order paid, email your customer or release stock, without polling. Messages follow the open Standard Webhooks specification, so ready-made libraries exist for most languages.
Set one up
In your dashboard open Webhooks, or use the API. Give a public https address (private networks are refused, when you save and again at every delivery). The first time you save, you receive the signing secret (whsec_…): copy it now, it is not shown again. You can rotate it at any time.
curl -X PUT "https://engine-production-0004.up.railway.app/api/v1/webhook" \
-H "Authorization: Bearer wc_test_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://alpha.example/hooks/waca"
}'Events
| Type | When it happens | data.object |
|---|---|---|
booking.confirmed | The customer’s payment succeeded. | Booking |
booking.active | The customer collected the car. | Booking |
booking.completed | The car came back. Your commission is released. | Booking |
booking.cancelled | The booking was cancelled; price.refunded says how much went back. | Booking |
booking.expired | The customer did not pay in time; the car was released. | Booking |
sale.reserved | The buyer’s deposit was paid; the car is held for about 7 days. | Sale order |
sale.paid | The balance reached escrow. | Sale order |
sale.completed | The car and papers were handed over. | Sale order |
sale.cancelled | The purchase was cancelled. | Sale order |
sale.expired | The deposit was not paid in time. | Sale order |
webhook.test | You pressed “send a test event”. | A message |
Only bookings and orders that your keys made raise events for you. New event types may be added: ignore the ones you do not handle, and answer 2xx.
The message
POST https://alpha.example/hooks/waca
webhook-id: evt_3f1c9a2b7d4e5f6a8b0c1d2e # the same on every retry
webhook-timestamp: 1767200000 # unix seconds
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2/3Rg…
content-type: application/json{
"id": "evt_3f1c9a2b7d4e5f6a8b0c1d2e",
"type": "booking.confirmed",
"created": 1767200000,
"livemode": true,
"data": {
"object": { "reference": "WC-7KQ2M9XA", "status": "confirmed", … the booking, exactly as GET /rentals/bookings/{reference} returns it … }
}
}The same event, with the same id and body, is what GET /events returns, so you can always catch up on anything you missed.
Verify the signature
Anyone who learns your address could send you a fake “paid” message. Before you trust one, check the signature. The signature is a base64 HMAC-SHA256, with your secret, of {webhook-id}.{webhook-timestamp}.{raw body}. Three rules: use the raw body (not re-serialised JSON), compare in constant time, and reject timestamps older than 5 minutes so an old message cannot be replayed.
Node.js
import crypto from 'node:crypto'
export function verify(secret, headers, rawBody) {
const id = headers['webhook-id'], ts = headers['webhook-timestamp'], sigs = headers['webhook-signature']
if (!id || !ts || !sigs || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
const key = Buffer.from(secret.replace('whsec_', ''), 'base64')
const expected = 'v1,' + crypto.createHmac('sha256', key).update(id + '.' + ts + '.' + rawBody).digest('base64')
return sigs.split(' ').some((s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)))
}Python
import base64, hashlib, hmac, time
def verify(secret: str, headers: dict, raw_body: bytes) -> bool:
id_, ts, sigs = headers.get("webhook-id"), headers.get("webhook-timestamp"), headers.get("webhook-signature")
if not (id_ and ts and sigs) or abs(time.time() - int(ts)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{id_}.{ts}.".encode() + raw_body
expected = "v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
return any(hmac.compare_digest(s, expected) for s in sigs.split(" "))PHP
function verify(string $secret, array $h, string $rawBody): bool {
$id = $h['webhook-id'] ?? ''; $ts = $h['webhook-timestamp'] ?? ''; $sigs = $h['webhook-signature'] ?? '';
if (!$id || !$ts || !$sigs || abs(time() - (int)$ts) > 300) return false;
$key = base64_decode(str_replace('whsec_', '', $secret));
$expected = 'v1,' . base64_encode(hash_hmac('sha256', "$id.$ts.$rawBody", $key, true));
foreach (explode(' ', $sigs) as $s) { if (hash_equals($expected, $s)) return true; }
return false;
}Handle it well
- Answer fast with 2xx. We wait 10 seconds. Do slow work after answering.
- Be idempotent. The same event arrives again on every retry, with the same
id. Remember the ids you handled and skip repeats. - Do not rely on order. Retries can arrive after later events. Use
data.object.statusand the booking’s own state, or fetch it again. - Redirects are not followed. Give the final address.
import express from 'express'
import { verify } from './verify.js'
const app = express()
// Verify against the RAW body: parse the JSON only after the signature checks out.
app.post('/hooks/waca', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verify(process.env.WACA_WEBHOOK_SECRET, req.headers, req.body.toString('utf8'))) return res.status(400).end()
const event = JSON.parse(req.body.toString('utf8'))
if (await alreadyHandled(event.id)) return res.status(200).end() // the same event id arrives again on every retry
switch (event.type) {
case 'booking.confirmed': await markPaid(event.data.object.metadata.order_id); break
case 'booking.cancelled': await refundOrder(event.data.object.metadata.order_id, event.data.object.price.refunded); break
case 'sale.paid': await startHandover(event.data.object.reference); break
}
await remember(event.id)
res.status(200).end() // answer fast: we wait 10 seconds
})Retries and pausing
Anything other than a 2xx within 10 seconds is a failure. We retry after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours, with the same body and id, then give up on that delivery (failed). After three deliveries in a row give up, the webhook is paused and we email you: new events are held, not lost. Fix your server, then resume (the dashboard, or POST /webhook/resume) and everything held is sent. The delivery log shows every attempt, the status code and the error, and you can retry any single delivery.
curl "https://engine-production-0004.up.railway.app/api/v1/webhook/deliveries?page=1&limit=20" \
-H "Authorization: Bearer wc_test_YOUR_KEY"Testing
- Send a
webhook.testevent from the dashboard orPOST /webhook/test: the answer says how your server responded. - With a test key, drive a booking through its life with the sandbox simulators and watch each event arrive (
livemode: false). - On your own computer, expose your server with a tunnel (ngrok, Cloudflare Tunnel) and give us that
httpsaddress. We refuse private and local addresses.
Or poll
No public address? GET /events lists the same events, newest first, with or without a webhook. Remember the id of the newest event you processed, and ask for events starting_after it.
curl "https://engine-production-0004.up.railway.app/api/v1/events" \
-H "Authorization: Bearer wc_test_YOUR_KEY"