Webhook Integration Guide
Receive real-time notifications for deal lifecycle events. This guide covers endpoint setup, signature verification, retry handling, and the dead letter queue.
Setting Up a Webhook Endpoint
Register your endpoint in Developers → Webhooks or via the API:
# Create a webhook endpoint
curl -X POST https://salesbooth.com/api/v1/webhooks \
-H "Authorization: Bearer $SB_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/salesbooth",
"events": ["deal.created", "deal.signature_added", "payment.received"],
"description": "Production webhook"
}'# Response — save the secret!
{
"error": false,
"data": {
"webhook_id": "wh_a1b2c3d4",
"url": "https://your-app.com/webhooks/salesbooth",
"events": ["deal.created", "deal.signature_added", "payment.received"],
"description": "Production webhook",
"status": "active",
"secret": "whsec_your_secret_here",
"created_at": "2026-03-18 12:00:00"
}
}The secret is only shown once. Store it in an environment variable or secret manager. If lost, rotate it from the dashboard.
Required response behavior
Your endpoint must return an HTTP 2xx status within 10 seconds. Timeouts and non-2xx responses trigger retries.
# Always return 200 quickly — process async if needed
HTTP/1.1 200 OK
Content-Type: application/json
{"received": true}Acknowledge the webhook immediately, then process the event in a background job or queue. This prevents timeouts if your processing logic is slow.
Signature Verification
Every webhook request includes two headers you must validate:
X-Salesbooth-Signature— HMAC-SHA256 signature in the formatv1=<hex>X-Salesbooth-Timestamp— Unix timestamp in seconds (prevents replay attacks)
Never process webhook events without verifying the signature. Any HTTP client can POST to your endpoint — signature verification proves the request came from Salesbooth.
How the signature is computed
signed_content = timestamp + "." + raw_request_body
signature = "v1=" + HMAC-SHA256(signed_content, signing_secret)const express = require('express');
const crypto = require('crypto');
const app = express();
const WEBHOOK_SECRET = process.env.SALESBOOTH_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300; // Reject requests older than 5 min
app.post('/webhooks/salesbooth',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['x-salesbooth-signature'];
const ts = req.headers['x-salesbooth-timestamp'];
// 1. Validate timestamp to prevent replay attacks
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(ts, 10)) > TOLERANCE_SECONDS) {
return res.status(400).json({ error: 'Timestamp out of tolerance' });
}
// 2. Compute expected signature
const signedContent = `${ts}.${req.body}`;
const expected = 'v1=' + crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedContent)
.digest('hex');
// 3. Constant-time comparison (prevents timing attacks)
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(401).json({ error: 'Invalid signature' });
}
// 4. Parse and handle the event
const event = JSON.parse(req.body);
handleEvent(event);
res.json({ received: true });
}
);
function handleEvent(event) {
switch (event.event) {
case 'deal.created':
console.log('New deal:', event.data.deal_id);
break;
case 'deal.signature_added':
provisionAccess(event.data.deal_id);
break;
case 'payment.received':
sendReceipt(event.data);
break;
}
}import hmac
import hashlib
import time
import os
from flask import Flask, request, jsonify, abort
app = Flask(__name__)
WEBHOOK_SECRET = os.environ['SALESBOOTH_WEBHOOK_SECRET']
TOLERANCE_SECONDS = 300
@app.route('/webhooks/salesbooth', methods=['POST'])
def webhook():
sig = request.headers.get('X-Salesbooth-Signature', '')
ts = request.headers.get('X-Salesbooth-Timestamp', '')
body = request.get_data()
# 1. Validate timestamp
try:
ts_int = int(ts)
except ValueError:
abort(400, 'Invalid timestamp')
if abs(int(time.time()) - ts_int) > TOLERANCE_SECONDS:
abort(400, 'Timestamp out of tolerance')
# 2. Compute expected signature
signed_content = f"{ts}.{body.decode('utf-8')}"
expected = 'v1=' + hmac.new(
WEBHOOK_SECRET.encode('utf-8'),
signed_content.encode('utf-8'),
hashlib.sha256
).hexdigest()
# 3. Constant-time comparison
if not hmac.compare_digest(sig, expected):
abort(401, 'Invalid signature')
# 4. Parse and handle
event = request.get_json(force=True)
handle_event(event)
return jsonify({'received': True})
def handle_event(event):
event_type = event.get('event')
if event_type == 'deal.created':
print('New deal:', event['data']['id'])
elif event_type == 'deal.signature_added':
provision_access(event['data']['deal_id'])
elif event_type == 'payment.received':
send_receipt(event['data'])<?php
define('WEBHOOK_SECRET', getenv('SALESBOOTH_WEBHOOK_SECRET'));
define('TOLERANCE_SECONDS', 300);
$payload = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_SALESBOOTH_SIGNATURE'] ?? '';
$ts = $_SERVER['HTTP_X_SALESBOOTH_TIMESTAMP'] ?? '';
// 1. Validate timestamp
$tsInt = (int) $ts;
if (abs(time() - $tsInt) > TOLERANCE_SECONDS) {
http_response_code(400);
exit(json_encode(['error' => 'Timestamp out of tolerance']));
}
// 2. Compute expected signature
$signed = $tsInt . '.' . $payload;
$expected = 'v1=' . hash_hmac('sha256', $signed, WEBHOOK_SECRET);
// 3. Constant-time comparison (hash_equals prevents timing attacks)
if (!hash_equals($expected, $sig)) {
http_response_code(401);
exit(json_encode(['error' => 'Invalid signature']));
}
// 4. Parse and handle
$event = json_decode($payload, true);
switch ($event['event']) {
case 'deal.created':
error_log('New deal: ' . $event['data']['id']);
break;
case 'deal.signature_added':
provisionAccess($event['data']['deal_id']);
break;
case 'payment.received':
sendReceipt($event['data']);
break;
}
http_response_code(200);
echo json_encode(['received' => true]);Event Types & Payloads
All events share the same envelope structure:
{
"event": "deal.created",
"timestamp": "2026-03-18T12:00:00+00:00",
"tenant_id": "tenant_abc123",
"data": {
// Event-specific payload
}
}Deal events
Deal settlement events
Deal participant events
Payment events
Negotiation events
Contract events
Subscription events
Customer events
Booking events
Escrow events
Saved configuration events
Delegation events
Agent & workflow events
Product catalog events
Billing events
Trust events
System events
Retries & Reliability
Salesbooth retries failed deliveries with exponential backoff. The system makes a maximum of 3 attempts (1 initial + 2 retries). After all attempts are exhausted the event is moved to the dead-letter queue.
Retry schedule
Deliveries are retried when your endpoint returns:
- HTTP 5xx (server error)
- HTTP 429 (rate limited — honors
Retry-After) - No response (timeout after 10 seconds)
HTTP 4xx responses (except 429) are treated as permanent failures — the event moves to the dead letter queue immediately.
Idempotency
Because webhooks are retried, your handler may receive the same event more than once. Use the event ID to deduplicate.
// ✅ Correct: check event ID before processing
const processedEvents = new Set(); // Or use a database
app.post('/webhooks/salesbooth', express.raw({ type: '*/*' }), (req, res) => {
// ... signature verification ...
const event = JSON.parse(req.body);
// Acknowledge immediately
res.json({ received: true });
// Deduplicate before processing
if (processedEvents.has(event.id)) {
console.log('Duplicate event, skipping:', event.id);
return;
}
processedEvents.add(event.id);
processEvent(event); // async, safe to proceed
});In production, store processed event IDs in your database (e.g., a processed_webhook_events table with a unique index on event_id). In-memory sets don't survive server restarts.
-- PostgreSQL example: idempotent event processing
CREATE TABLE processed_webhook_events (
event_id VARCHAR(64) PRIMARY KEY,
processed_at TIMESTAMPTZ DEFAULT NOW()
);
-- In your handler:
INSERT INTO processed_webhook_events (event_id)
VALUES ($1)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;
-- If no row returned, event was already processed — skip itDead Letter Queue & Replay
After all retries are exhausted, the delivery is moved to the dead letter queue. You can view and replay failed deliveries from Developers → Dead Letter or via the API.
# List dead letter events
curl https://salesbooth.com/api/v1/webhooks/dead_letter \
-H "Authorization: Bearer $SB_API_KEY"# Retry a specific failed delivery
curl -X POST https://salesbooth.com/api/v1/webhooks/retry \
-H "Authorization: Bearer $SB_API_KEY" \
-H "Content-Type: application/json" \
-d '{"delivery_id": "del_a1b2c3d4"}'# Replay all events for a webhook
curl -X POST https://salesbooth.com/api/v1/webhooks/replay \
-H "Authorization: Bearer $SB_API_KEY" \
-H "Content-Type: application/json" \
-d '{"webhook_id": "wh_a1b2c3d4"}'Enable webhook failure alerts in Developers → Webhooks → Settings to receive email notifications when deliveries fail consecutively. This lets you respond before events expire from the dead letter queue (retained for 30 days).
Testing Webhooks
Use the API Playground or send a test event from the dashboard.
Send a test event via API
curl -X POST "https://salesbooth.com/api/v1/webhooks/test?id=wh_a1b2c3d4" \
-H "Authorization: Bearer $SB_API_KEY" \
-H "Content-Type: application/json" \
-d '{"event_type": "deal.created"}'Local development with tunneling
# Using ngrok to expose localhost:3000
ngrok http 3000
# Use the https://xxx.ngrok.io URL as your webhook endpoint
# in Developers → WebhooksVerify your signature implementation
# The test event includes a real signature you can verify
# Check your server logs for: "Signature verified ✓"
# If verification fails, common causes:
# 1. Reading req.body as JSON (not raw bytes) before hashing
# → Use express.raw() not express.json()
# 2. Not using constant-time comparison (crypto.timingSafeEqual)
# 3. Wrong secret — check SALESBOOTH_WEBHOOK_SECRET matches dashboard value