Overview
Webhooks allow CleanLife to push real-time notifications to your server whenever important events happen on bookings associated with your partner account. Instead of polling the API for status changes, you register an HTTPS endpoint and CleanLife will POST event payloads to it automatically.
Architecture Overview
Subscription Lifecycle
1. Register Allowed Domain
Before creating a subscription, your partner account must have at least one allowed webhook domain configured. This is done by the CleanLife integration team. The domain must match the hostname of your webhook URL.
2. Create a Subscription
Call POST /partners/webhooks/subscriptions with your endpoint URL and the events you want to subscribe to.
curl -X POST "https://apiv3.thecleanlife.dev/v1/partners/webhooks/subscriptions" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourplatform.com/webhooks/cleanlife",
"events": ["booking.created", "booking.cancelled", "booking.appointment_status_changed"]
}'
The response includes your webhook secret — this is the only time the secret is returned. Store it securely; you will need it to verify webhook signatures.
3. Receive and Verify Events
CleanLife will POST events to your URL. Verify the signature on each delivery to ensure authenticity.
4. Manage Your Subscription
- Update the URL or subscribed events via
PATCH /partners/webhooks/subscriptions/:id
- Rotate the secret via
POST /partners/webhooks/subscriptions/:id/rotate-secret
- Deactivate via
PATCH with status: INACTIVE or delete via DELETE
URL Requirements
Webhook URLs must satisfy all of the following:
| Requirement | Details |
|---|
| Protocol | https:// (required by default; enforced per-partner setting) |
| Hostname | Must match one of your registered allowed domains |
| Blocked hosts | localhost, 127.x.x.x, 10.x.x.x, 172.16.x.x–172.31.x.x, 192.168.x.x, 169.254.x.x (link-local / metadata IP) are blocked |
| IPv6 loopback | ::1 and [::1] are blocked |
Delivery
CleanLife sends a POST request to your URL with the following:
Headers:
| Header | Description |
|---|
Content-Type | application/json |
User-Agent | CleanOS-Partner-Webhooks/1.0 |
X-CleanOS-Delivery-Id | UUID of this specific delivery attempt |
X-CleanOS-Event | The event type (e.g., booking.created) |
X-CleanOS-Signature | HMAC-SHA256 signature (see Signature Verification) |
X-CleanOS-Timestamp | ISO 8601 timestamp of when the delivery was dispatched |
Body:
{
"event": "booking.created",
"deliveryId": "ffffffff-0000-0000-0000-000000000001",
"payload": { ... }
}
| Field | Type | Description |
|---|
event | string | The event type |
deliveryId | UUID | Unique identifier for this delivery. Use for idempotency. |
payload | object | Event-specific data |
Signature Verification
Every webhook delivery is signed using HMAC-SHA256. You must verify this signature to ensure the delivery came from CleanLife and was not tampered with.
Algorithm
signature = "sha256=" + HMAC_SHA256(secret, timestamp + "." + rawBody)
Where:
secret is the webhook subscription secret (returned at creation or rotation)
timestamp is the value of the X-CleanOS-Timestamp header
rawBody is the stable-serialized JSON body (keys sorted alphabetically, no extra whitespace)
The JSON body is serialized with keys sorted alphabetically (stableJson format). Do not compare against a pretty-printed or differently-ordered version of the body.
Verification Examples
Node.js:
const crypto = require('crypto');
function verifyWebhook(secret, rawBody, timestamp, signatureHeader) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
// Use timing-safe comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader)
);
}
// In your express handler:
app.post('/webhooks/cleanlife', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-cleanos-signature'];
const timestamp = req.headers['x-cleanos-timestamp'];
const rawBody = req.body.toString('utf8');
if (!verifyWebhook(process.env.WEBHOOK_SECRET, rawBody, timestamp, signature)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(rawBody);
// Process event...
res.status(200).json({ received: true });
});
Python:
import hmac
import hashlib
def verify_webhook(secret: str, raw_body: str, timestamp: str, signature_header: str) -> bool:
expected = 'sha256=' + hmac.new(
secret.encode('utf-8'),
f'{timestamp}.{raw_body}'.encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header)
PHP:
function verifyWebhook(string $secret, string $rawBody, string $timestamp, string $signatureHeader): bool {
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signatureHeader);
}
C#:
using System.Security.Cryptography;
using System.Text;
bool VerifyWebhook(string secret, string rawBody, string timestamp, string signatureHeader)
{
var key = Encoding.UTF8.GetBytes(secret);
var message = Encoding.UTF8.GetBytes($"{timestamp}.{rawBody}");
using var hmac = new HMACSHA256(key);
var hash = hmac.ComputeHash(message);
var expected = "sha256=" + BitConverter.ToString(hash).Replace("-", "").ToLower();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(signatureHeader)
);
}
Security tip: Always use a timing-safe comparison function (e.g., crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python, hash_equals in PHP, CryptographicOperations.FixedTimeEquals in C#) to prevent timing attacks.
Retry Policy
If your endpoint returns a non-2xx HTTP response or times out, CleanLife retries the delivery automatically:
| Attempt | Delay After Failure |
|---|
| 1st retry | 1 minute |
| 2nd retry | 5 minutes |
| 3rd retry | 15 minutes |
| After 3rd failure | Status set to FAILED (no further automatic retries) |
Timeout per attempt: 10 seconds
After a delivery reaches FAILED status, you can trigger a manual retry via POST /partners/webhooks/deliveries/:id/retry.
Delivery Guarantees
- At-least-once delivery: CleanLife guarantees that each event will be delivered at least once. Use the
deliveryId field to detect and deduplicate retries in your system.
- Not exactly-once: Your handler must be idempotent.
- Ordering: Delivery order is best-effort (not guaranteed). Events for the same booking may arrive slightly out of order if retries are in flight.
Duplicate Event Handling
Your webhook handler should be idempotent. Use the deliveryId as a unique key:
app.post('/webhooks/cleanlife', async (req, res) => {
const { event, deliveryId, payload } = req.body;
// Idempotency check
if (await db.webhookDeliveries.exists(deliveryId)) {
return res.status(200).json({ received: true, duplicate: true });
}
await db.webhookDeliveries.markReceived(deliveryId);
await processEvent(event, payload);
res.status(200).json({ received: true });
});
Responding to Webhooks
Your endpoint must respond with an HTTP 2xx status code within 10 seconds. Any response with a status code ≥ 300 (or a timeout) is treated as a failure and triggers the retry schedule.
Best practices:
- Respond immediately with
200 OK and process the event asynchronously.
- Do not perform slow database operations or external API calls synchronously in the webhook handler.
Available Events
| Event | Trigger |
|---|
booking.created | A new booking was successfully created via the Partner API |
booking.updated | A booking’s date, timeslot, or externalReference was updated |
booking.cancelled | A booking was cancelled (via Partner API or by CleanLife operations) |
booking.payment_failed | An asynchronous payment link processing failed (CLEANOS responsibility) |
booking.payment_confirmed | A partner confirmed payment for a PARTNER-responsibility booking |
booking.appointment_status_changed | The linked service appointment changed status (e.g., Scheduled → In Progress) |
Event Payloads
booking.created, booking.updated, booking.cancelled, booking.payment_confirmed
These events share the same booking object payload:
{
"event": "booking.created",
"deliveryId": "ffffffff-0000-0000-0000-000000000001",
"payload": {
"bookingId": "11111111-0000-0000-0000-000000000001",
"externalReference": "ORDER-20260615-001",
"appointmentId": "22222222-0000-0000-0000-000000000002",
"status": "in progress",
"paymentStatus": "NOT_REQUIRED",
"trackingReference": "SA-0042",
"date": "2026-06-20T00:00:00.000Z",
"timeslot": {
"startAt": "09:00",
"endAt": "12:00",
"endsAtNextDay": false
},
"appointment": {
"id": "22222222-0000-0000-0000-000000000002",
"name": "SA-0042",
"status": "Scheduled",
"scheduledStartDateTime": "2026-06-20T09:00:00+03:00",
"scheduledEndDateTime": "2026-06-20T12:00:00+03:00"
}
}
}
booking.appointment_status_changed
Same as above but with an additional appointmentStatusChange field:
{
"event": "booking.appointment_status_changed",
"deliveryId": "ffffffff-0000-0000-0000-000000000002",
"payload": {
"bookingId": "11111111-0000-0000-0000-000000000001",
"externalReference": "ORDER-20260615-001",
"status": "in progress",
"paymentStatus": "NOT_REQUIRED",
"trackingReference": "SA-0042",
"date": "2026-06-20T00:00:00.000Z",
"timeslot": {
"startAt": "09:00",
"endAt": "12:00",
"endsAtNextDay": false
},
"appointment": {
"id": "22222222-0000-0000-0000-000000000002",
"name": "SA-0042",
"status": "In Progress",
"scheduledStartDateTime": "2026-06-20T09:00:00+03:00",
"scheduledEndDateTime": "2026-06-20T12:00:00+03:00"
},
"appointmentStatusChange": {
"from": "Scheduled",
"to": "In Progress",
"changedAt": "2026-06-20T09:03:00+03:00"
}
}
}
booking.payment_failed
{
"event": "booking.payment_failed",
"deliveryId": "ffffffff-0000-0000-0000-000000000003",
"payload": {
"bookingId": "11111111-0000-0000-0000-000000000001",
"reason": "Payment gateway returned an error"
}
}
Security Best Practices
- Always verify signatures. Never process a webhook without verifying the
X-CleanOS-Signature header.
- Rotate secrets periodically. Use
POST /partners/webhooks/subscriptions/:id/rotate-secret to rotate your secret. Update your system immediately after rotation — old signatures will stop validating.
- Use HTTPS only. Webhook URLs must use HTTPS to protect payloads in transit.
- Respond quickly. Process webhooks asynchronously. Slow handlers cause timeouts and unnecessary retries.
- Implement idempotency. Use
deliveryId to deduplicate retried events.
- Protect your webhook endpoint. Verify the source by validating the signature before processing any business logic.