Webhooks
Webhooks notify your application in real time when events occur (e.g. a booking is created or updated). You register a URL; CitaPro sends a POST request to that URL with the event payload.
For the payload format, events list, and trackable fields per event, see the Webhooks API Reference. This guide focuses on receiving and verifying webhooks.
Receiving webhooks
Section titled “Receiving webhooks”Use the raw body
Section titled “Use the raw body”Signature verification uses the exact raw request body. If your framework parses the body (e.g. as JSON) before your handler runs, the string used for verification may differ (encoding, key order, spaces) and verification will fail.
- Do read the body as a raw string/buffer and pass that to your HMAC function.
- Do not use the parsed JSON body to recompute the signature.
Return 2xx quickly
Section titled “Return 2xx quickly”Respond with a 2xx status before doing heavy work. If your endpoint is slow or returns an error, CitaPro will retry (see Retries). Acknowledge receipt first, then process the event asynchronously if needed.
Idempotency
Section titled “Idempotency”Use the X-CitaPro-Delivery-Id header as a unique id for the delivery. Store processed delivery IDs and ignore duplicates so the same event is not applied twice if a retry arrives.
Verifying the signature
Section titled “Verifying the signature”CitaPro signs each request with HMAC-SHA256 using your webhook secret. The signature is sent in the X-CitaPro-Signature header. You must verify it to ensure the request came from CitaPro and was not altered.
Steps:
- Read the raw request body (unchanged).
- Compute HMAC-SHA256(rawBody, secret). Use the same encoding as the header (hex).
- Compare the result with
X-CitaPro-Signatureusing a constant-time comparison to avoid timing attacks.
The secret is returned only when you create the webhook. Store it securely (e.g. environment variable).
Node.js (Express)
Section titled “Node.js (Express)”Use express.raw() for the webhook route so the body stays unparsed. If you use express.json() globally, register the webhook route before it, or use a path-specific middleware.
const express = require('express');const crypto = require('crypto');
const app = express();const WEBHOOK_SECRET = process.env.CITAPRO_WEBHOOK_SECRET;
// Use raw body for the webhook path onlyapp.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => { const rawBody = req.body; // Buffer const signature = req.headers['x-citapro-signature'];
if (!signature || !WEBHOOK_SECRET) { return res.status(401).send('Missing signature or secret'); }
const expected = crypto .createHmac('sha256', WEBHOOK_SECRET) .update(rawBody) .digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(signature, 'hex'))) { return res.status(401).send('Invalid signature'); }
const payload = JSON.parse(rawBody.toString()); const event = req.headers['x-citapro-event']; const deliveryId = req.headers['x-citapro-delivery-id'];
// Optional: idempotency - skip if deliveryId already processed // ... handle payload.event and payload.data ...
res.status(200).send('OK');});
// Other routes can use JSONapp.use(express.json());Use the raw input stream. Do not use $_POST or already-parsed JSON for the signature.
<?php$webhookSecret = getenv('CITAPRO_WEBHOOK_SECRET');$rawBody = file_get_contents('php://input');$signature = $_SERVER['HTTP_X_CITAPRO_SIGNATURE'] ?? '';
if (empty($signature) || empty($webhookSecret)) { http_response_code(401); exit('Missing signature or secret');}
$expected = hash_hmac('sha256', $rawBody, $webhookSecret);
if (!hash_equals($expected, $signature)) { http_response_code(401); exit('Invalid signature');}
$payload = json_decode($rawBody, true);$event = $_SERVER['HTTP_X_CITAPRO_EVENT'] ?? '';$deliveryId = $_SERVER['HTTP_X_CITAPRO_DELIVERY_ID'] ?? '';
// Handle $payload['event'] and $payload['data'] ...http_response_code(200);echo 'OK';Python (Flask)
Section titled “Python (Flask)”Use request.get_data() to get the raw bytes. Do not use request.get_json() for verification.
import hmacimport hashlibimport osfrom flask import Flask, request
app = Flask(__name__)WEBHOOK_SECRET = os.environ.get('CITAPRO_WEBHOOK_SECRET', '').encode('utf-8')
@app.route('/webhook', methods=['POST'])def webhook(): raw_body = request.get_data() signature = request.headers.get('X-CitaPro-Signature', '')
if not signature or not WEBHOOK_SECRET: return 'Missing signature or secret', 401
expected = hmac.new( WEBHOOK_SECRET, raw_body, hashlib.sha256 ).hexdigest()
if not hmac.compare_digest(expected, signature): return 'Invalid signature', 401
payload = request.get_json(force=True, silent=True) or {} event = request.headers.get('X-CitaPro-Event', '') delivery_id = request.headers.get('X-CitaPro-Delivery-Id', '')
# Handle payload['event'] and payload['data'] ... return 'OK', 200Ruby (Rack / Sinatra)
Section titled “Ruby (Rack / Sinatra)”Read the body with request.body.read and rewind if you need to parse it again.
require 'openssl'require 'json'
WEBHOOK_SECRET = ENV['CITAPRO_WEBHOOK_SECRET']
post '/webhook' do raw_body = request.body.read signature = request.env['HTTP_X_CITAPRO_SIGNATURE'].to_s
if signature.empty? || WEBHOOK_SECRET.to_s.empty? status 401 return 'Missing signature or secret' end
expected = OpenSSL::HMAC.hexdigest('SHA256', WEBHOOK_SECRET, raw_body)
unless Rack::Utils.secure_compare(expected, signature) status 401 return 'Invalid signature' end
payload = JSON.parse(raw_body) event = request.env['HTTP_X_CITAPRO_EVENT'] delivery_id = request.env['HTTP_X_CITAPRO_DELIVERY_ID']
# Handle payload['event'] and payload['data'] ... status 200 'OK'endNote: Use a constant-time comparison (e.g. Rack::Utils.secure_compare) instead of == to avoid timing attacks.
Read the raw body with io.ReadAll and use hmac.Equal for comparison.
package main
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "io" "net/http" "os")
var webhookSecret = []byte(os.Getenv("CITAPRO_WEBHOOK_SECRET"))
func webhookHandler(w http.ResponseWriter, r *http.Request) { rawBody, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } defer r.Body.Close()
signature := r.Header.Get("X-CitaPro-Signature") if signature == "" || len(webhookSecret) == 0 { http.Error(w, "Missing signature or secret", http.StatusUnauthorized) return }
mac := hmac.New(sha256.New, webhookSecret) mac.Write(rawBody) expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(signature)) { http.Error(w, "Invalid signature", http.StatusUnauthorized) return }
event := r.Header.Get("X-CitaPro-Event") deliveryID := r.Header.Get("X-CitaPro-Delivery-Id") // Parse JSON from rawBody and handle payload["event"], payload["data"] ...
w.WriteHeader(http.StatusOK) w.Write([]byte("OK"))}Optional: timestamp tolerance
Section titled “Optional: timestamp tolerance”To reduce the impact of replay attacks, you can reject requests that are too old. Use the X-CitaPro-Timestamp header (Unix time in seconds). For example, reject if abs(now - timestamp) > 300 (5 minutes).
Retries
Section titled “Retries”If your endpoint does not return a 2xx status, CitaPro retries up to 3 times with progressive backoff: 10s, 60s, 5min. Use X-CitaPro-Delivery-Id to implement idempotency and avoid processing the same delivery more than once.
Summary
Section titled “Summary”| Step | Action |
|---|---|
| 1 | Read the raw request body (do not parse before verification). |
| 2 | Compute HMAC-SHA256(rawBody, secret) (hex). |
| 3 | Compare with X-CitaPro-Signature using constant-time comparison. |
| 4 | Return 2xx quickly; process the event asynchronously if needed. |
| 5 | Use X-CitaPro-Delivery-Id to deduplicate retries. |
For payload structure, event types, and trackable fields, see the Webhooks API Reference.