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

TypeWhen it happensdata.object
booking.confirmedThe customer’s payment succeeded.Booking
booking.activeThe customer collected the car.Booking
booking.completedThe car came back. Your commission is released.Booking
booking.cancelledThe booking was cancelled; price.refunded says how much went back.Booking
booking.expiredThe customer did not pay in time; the car was released.Booking
sale.reservedThe buyer’s deposit was paid; the car is held for about 7 days.Sale order
sale.paidThe balance reached escrow.Sale order
sale.completedThe car and papers were handed over.Sale order
sale.cancelledThe purchase was cancelled.Sale order
sale.expiredThe deposit was not paid in time.Sale order
webhook.testYou 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

What arrives at your address
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
The body
{
  "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

node
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

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

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;
}
Each of these samples is run against our real signatures in our own tests, so they work as written.

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.status and the booking’s own state, or fetch it again.
  • Redirects are not followed. Give the final address.
A complete receiver (Express)
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.test event from the dashboard or POST /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 https address. 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"