Skip to main content

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:
RequirementDetails
Protocolhttps:// (required by default; enforced per-partner setting)
HostnameMust match one of your registered allowed domains
Blocked hostslocalhost, 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

Request Format

CleanLife sends a POST request to your URL with the following: Headers:
HeaderDescription
Content-Typeapplication/json
User-AgentCleanOS-Partner-Webhooks/1.0
X-CleanOS-Delivery-IdUUID of this specific delivery attempt
X-CleanOS-EventThe event type (e.g., booking.created)
X-CleanOS-SignatureHMAC-SHA256 signature (see Signature Verification)
X-CleanOS-TimestampISO 8601 timestamp of when the delivery was dispatched
Body:
{
  "event": "booking.created",
  "deliveryId": "ffffffff-0000-0000-0000-000000000001",
  "payload": { ... }
}
FieldTypeDescription
eventstringThe event type
deliveryIdUUIDUnique identifier for this delivery. Use for idempotency.
payloadobjectEvent-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:
AttemptDelay After Failure
1st retry1 minute
2nd retry5 minutes
3rd retry15 minutes
After 3rd failureStatus 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

EventTrigger
booking.createdA new booking was successfully created via the Partner API
booking.updatedA booking’s date, timeslot, or externalReference was updated
booking.cancelledA booking was cancelled (via Partner API or by CleanLife operations)
booking.payment_failedAn asynchronous payment link processing failed (CLEANOS responsibility)
booking.payment_confirmedA partner confirmed payment for a PARTNER-responsibility booking
booking.appointment_status_changedThe 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

  1. Always verify signatures. Never process a webhook without verifying the X-CleanOS-Signature header.
  2. 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.
  3. Use HTTPS only. Webhook URLs must use HTTPS to protect payloads in transit.
  4. Respond quickly. Process webhooks asynchronously. Slow handlers cause timeouts and unnecessary retries.
  5. Implement idempotency. Use deliveryId to deduplicate retried events.
  6. Protect your webhook endpoint. Verify the source by validating the signature before processing any business logic.