Webhooks
Let's Book sends real-time notifications to your server when events happen. No polling required.
How webhooks work
When a booking gets confirmed, a customer registers, or any other event occurs, we send an HTTP POST request to your webhook URL with event details. Your server processes the data and responds within 5 seconds with a 2xx status code to confirm receipt. If we don't get a 2xx response, we'll retry the webhooks a few times.
What you need to do
- Create a public HTTPS endpoint that accepts POST requests
- Create a webhook from the integrations dashboard or through the API.
- Select which events you want to receive
- Store the webhook secret for signature verification
Webhook payload structure
Every webhook POST request contains:
{
"event": "booking.confirmed",
"occurredOn": "2025-11-17T18:34:11+00:00",
"data": {
...
}
}
See the API documentation for the complete list of webhook events and their payload schemas.
Verify webhook signatures
Webhook subscriptions have a secret. You should securely store this secret in your systems as you can use it to verify the request came from Let's Book and wasn't tampered with.
When we call your webhook URL, we send along a X-Webhook-Signature header. The signature is an HMAC SHA-256 hash of the request body using your webhook secret as the key. You should also calculate the signature based on the secret you have and verify it corresponds to the signature we sent.
How to verify
- Node (Express)
- PHP (Laravel)
- PHP (Plain)
- Python
- Ruby
- C#
const express = require('express');
const crypto = require('crypto');
const path = require('path');
// Load environment variables via dotenv (most popular package)
require('dotenv').config({ path: path.resolve(__dirname, '.env') });
const app = express();
function verifyWebhookSignature(payload, signature, secret) {
if (!signature || typeof signature !== 'string') return false;
// Compute HMAC using the raw payload
const calculatedSignatureHex = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
// Compare in constant time using 32-byte buffers
const expected = Buffer.from(calculatedSignatureHex, 'hex');
const provided = Buffer.from(signature, 'hex');
return crypto.timingSafeEqual(expected, provided);
}
// Express.js example
app.post(
'/webhooks/letsbook',
// Parse the body as raw Buffer so we can verify the signature
express.raw({ type: 'application/json' }),
(req, res) => {
const secret = process.env.LETSBOOK_WEBHOOK_SECRET; // Place the secret in your environment
const signature = req.headers['x-webhook-signature'];
const payloadBuffer = req.body;
const payload = Buffer.isBuffer(payloadBuffer)
? payloadBuffer.toString()
: '';
if (!verifyWebhookSignature(payload, signature, secret)) {
return res.status(401).send('Invalid signature');
}
// ...parse JSON safely
return res.status(200).send('OK');
}
);
app.listen(3000, () => {
console.log(`Server is humming on http://localhost:3000`);
});
Using Laravel, you can leverage middleware. Create the new middleware using php artisan make:middleware ValidateLetsBookWebhookSignature and use the code below:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ValidateLetsBookWebhookSignature
{
public function handle(Request $request, Closure $next): Response
{
$payload = $request->getContent();
$signature = $request->header('X-Webhook-Signature');
$secret = config('services.letsbook.webhook_secret');
$calculatedSignature = hash_hmac('sha256', $payload, $secret);
if (! hash_equals($calculatedSignature, $signature)) {
return response('Invalid signature', 401);
}
return $next($request);
}
}
Add the webhook secret to config/services.php:
'letsbook' => [
'webhook_secret' => env('LETSBOOK_WEBHOOK_SECRET'),
],
Add to your .env:
LETSBOOK_WEBHOOK_SECRET=your_webhook_secret_from_dashboard
Add the middleware to the route:
Route::post('/webhooks/letsbook', [WebhookController::class, 'handle'])
->middleware([\App\Http\Middleware\ValidateLetsBookWebhookSignature::class]);
<?php
declare(strict_types=1);
function verifyWebhookSignature(string $payload, string $signature, string $secret): bool {
$calculated = hash_hmac('sha256', $payload, $secret);
return hash_equals($calculated, $signature);
}
$payload = file_get_contents('php://input') ?: '';
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$secret = $_ENV['WEBHOOK_SECRET'];
if (!verifyWebhookSignature($payload, $signature, $secret)) {
http_response_code(401);
exit('Invalid signature');
}
$event = json_decode($payload, associative: true, flags: JSON_THROW_ON_ERROR);
// Handle the event...
import hmac
import hashlib
def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool:
calculated_signature = hmac.new(
secret.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(calculated_signature, signature)
# Flask example
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/webhooks/letsbook', methods=['POST'])
def webhook():
payload = request.get_data()
signature = request.headers.get('X-Webhook-Signature', '')
secret = 'your_webhook_secret_from_dashboard'
if not verify_webhook_signature(payload, signature, secret):
return 'Invalid signature', 401
event = request.get_json()
# Process the event
return 'OK', 200
# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :verify_webhook_signature
def letsbook
event = JSON.parse(request.raw_post)
# Process the event
head :ok
end
private
def verify_webhook_signature
payload = request.raw_post
signature = request.headers['X-Webhook-Signature'].to_s
secret = Rails.application.credentials.letsbook_webhook_secret
calculated_signature = OpenSSL::HMAC.hexdigest('SHA256', secret, payload)
unless Rack::Utils.secure_compare(calculated_signature, signature)
render plain: 'Invalid signature', status: :unauthorized
end
end
end
Add to your config/routes.rb:
post '/webhooks/letsbook', to: 'webhooks#letsbook'
Store the webhook secret in your credentials:
rails credentials:edit
Add:
letsbook_webhook_secret: your_webhook_secret_from_dashboard
using System;
using System.Security.Cryptography;
using System.Text;
public class WebhookSignatureValidator
{
public static bool VerifyWebhookSignature(string payload, string signature, string secret)
{
using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)))
{
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
var calculatedSignature = BitConverter.ToString(hash).Replace("-", "").ToLower();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(calculatedSignature),
Encoding.UTF8.GetBytes(signature)
);
}
}
}
// ASP.NET Core example
[HttpPost("webhooks/letsbook")]
public async Task<IActionResult> Webhook()
{
using var reader = new StreamReader(Request.Body);
var payload = await reader.ReadToEndAsync();
var signature = Request.Headers["X-Webhook-Signature"].FirstOrDefault() ?? "";
var secret = "your_webhook_secret_from_dashboard";
if (!WebhookSignatureValidator.VerifyWebhookSignature(payload, signature, secret))
{
return Unauthorized("Invalid signature");
}
var eventData = JsonSerializer.Deserialize<WebhookEvent>(payload);
// Process the event
return Ok();
}
Testing webhooks
- In order to test your webhook while developing, you need to make it publicly accessible to the internet. You can use a tool like ngrok or Cloudflare Tunnels for this.
- Create a test webhook in the dashboard
- Trigger test events by creating bookings in your test environment
- Verify your signature validation works and your server responds correctly
Best practices checklist
- Always verify signatures - Check the
X-Webhook-Signatureheader on every request. Don't process webhooks without verification. - Return
2xxquickly - Respond fast. Process heavy operations in a background job after acknowledging receipt. - Handle duplicates - Network issues can cause duplicate deliveries. Use the event data to detect and skip duplicates.
- Log everything - Keep webhook logs for debugging. Store the raw payload, headers, and your processing results.