Webhooks
Register an endpoint and we'll POST a signed event to it whenever booking or invoice activity happens — so your systems stay in sync without polling.
Events
Subscribe to any of these event types when you create an endpoint:
| Event | When it fires | Payload data |
|---|---|---|
booking.created | A booking is created (via the API or your booking pages). | Booking |
booking.updated | A booking's status changes to anything except cancelled. | Booking |
booking.cancelled | A booking is cancelled. | Booking |
booking.refunded | A booking payment is refunded (fully or partially). | Refund summary |
invoice.created | An invoice is created. | Invoice |
invoice.paid | An invoice becomes fully paid. | Payment summary |
Register an endpoint
Create a webhook with an https URL and the events you care about.
The response includes a secret — this is the only time
it's returned, so store it securely. You'll use it to verify every delivery.
Manage endpoints any time with the Webhooks endpoints in the reference.
Request
curl -X POST https://app.appointment.dev/api/v1/webhooks \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks",
"events": ["booking.created", "booking.cancelled"]
}'
Response
{
"data": {
"endpoint_id": 1,
"url": "https://example.com/webhooks",
"events": ["booking.created", "booking.cancelled"],
"status": "enabled",
"secret": "whsec_182022b9888a6d2f2307..."
},
"meta": { "request_id": "req_1a2b3c4d5e6f7a8b" }
}
The event payload
Each delivery is a POST with a JSON body and these headers:
X-Appointment-Event— the event type.X-Appointment-Delivery— the unique event id.X-Appointment-Signature—t=<timestamp>,v1=<signature>.
Respond with any 2xx status to acknowledge receipt.
POST body
{
"id": "evt_d49eba5792d7699d5a5d",
"type": "booking.created",
"created": 1785148251,
"data": {
"booking_id": 1001,
"status": "confirmed",
"customer_name": "Ada Lovelace"
},
"meta": { "business_id": 7 }
}
Verifying signatures
Before trusting a delivery, recompute the signature and compare it. Take the
t and v1 values from the
X-Appointment-Signature header, then compute
HMAC-SHA256(t + "." + rawBody, secret) and check it equals
v1. Always compare using a constant-time function.
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_APPOINTMENT_SIGNATURE'];
parse_str(strtr($header, ',', '&'), $p); // t=..., v1=...
$expected = hash_hmac(
'sha256', $p['t'] . '.' . $payload, $secret
);
if (!hash_equals($expected, $p['v1'])) {
http_response_code(400);
exit;
}
$event = json_decode($payload, true);
const crypto = require("crypto");
// rawBody must be the exact bytes received.
const header = req.headers["x-appointment-signature"];
const p = Object.fromEntries(
header.split(",").map((kv) => kv.split("="))
);
const expected = crypto
.createHmac("sha256", secret)
.update(p.t + "." + rawBody)
.digest("hex");
if (
!crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(p.v1)
)
) {
return res.status(400).end();
}
const event = JSON.parse(rawBody);
Retries & delivery
If your endpoint doesn't return a 2xx, we retry with exponential
backoff (roughly 1, 2, 4, 8 and 16 minutes) for up to six attempts, then mark the
delivery failed. Inspect recent attempts with
GET /api/v1/webhooks/{id}/deliveries.
id so retries are harmless.