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://api.salesbooth.com/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,
"success": true,
"data": {
"webhook": {
"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-18T12:00:00+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 delivery includes the following headers:
X-Salesbooth-Signature— HMAC-SHA256 signature in the formatv1=<hex>X-Salesbooth-Timestamp— Unix timestamp in seconds (prevents replay attacks)X-Salesbooth-Signature-Version— Signature algorithm version (currentlyv1); check this before computing the expected signatureX-Salesbooth-Event— Event type string (e.g.deal.created); lets you route events without parsing the JSON bodyX-Salesbooth-Delivery-Id— Unique delivery identifier; use this for idempotency (see Idempotency)X-Salesbooth-Replayed— Present and set totruewhen the delivery is a manual replay from the dead-letter queue; omitted on first-attempt deliveries
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)
const sigBuf = Buffer.from(sig);
const expectedBuf = Buffer.from(expected);
if (sigBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(sigBuf, expectedBuf)) {
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']['deal_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']['deal_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 165 supported event types are documented below, grouped by category. For machine-readable enumeration see the WebhookEventType enum in the OpenAPI spec. 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.closed payload example
The deal.closed event delivers the formatted closed deal returned by DealService::getDeal(), including enriched customer, pricing, signature, outcome, and deal intelligence fields.
{
"event": "deal.closed",
"timestamp": "2026-03-18T12:00:00+00:00",
"tenant_id": "tenant_abc123",
"data": {
"deal_id": "deal_123",
"status": "closed",
"currency": "USD",
"completed_at": "2026-03-18T12:00:00+00:00",
"customer": {
"customer_id": "cust_123",
"name": "Northwind Renovations",
"email": "owner@northwind.example"
},
"line_items": [
{
"item_id": "item_123",
"product_id": "prod_kitchen_design",
"quantity": 1
}
],
"pricing": {
"currency": "USD",
"discounts": [],
"total": {
"amount": "2499.00",
"currency": "USD",
"formatted": "$2,499.00"
}
},
"terms": {
"signature": {
"signed_at": "2026-03-18T11:54:00+00:00"
}
},
"payment": {
"status": "paid",
"paid_at": "2026-03-18T11:58:00+00:00"
},
"escrow": null,
"outcome": {
"status": "won"
},
"deal_score": {
"score": 87,
"win_probability": 0.91
}
}
}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
Federation events
Portal 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
Any non-2xx response is treated as a failed delivery and retried on the same schedule until the 3-attempt limit is exhausted. The current delivery service does not inspect response headers such as Retry-After.
Retries are scheduled when your endpoint returns:
- HTTP 4xx responses
- HTTP 5xx (server error)
- HTTP 429 (rate limited)
- No response (timeout after 10 seconds)
If all 3 attempts fail, the event is then moved to the dead-letter queue.
Idempotency
Because webhooks are retried, your handler may receive the same event more than once. Use the delivery ID from the X-Salesbooth-Delivery-Id header to deduplicate.
// ✅ Correct: check delivery 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);
const deliveryId = req.headers['x-salesbooth-delivery-id'];
// Acknowledge immediately
res.json({ received: true });
// Deduplicate before processing
if (processedEvents.has(deliveryId)) {
console.log('Duplicate event, skipping:', deliveryId);
return;
}
processedEvents.add(deliveryId);
processEvent(event); // async, safe to proceed
});In production, store processed delivery IDs in your database (e.g., a processed_webhook_events table with a unique index on delivery_id). In-memory sets don't survive server restarts.
-- PostgreSQL example: idempotent event processing
CREATE TABLE processed_webhook_events (
delivery_id VARCHAR(64) PRIMARY KEY, -- store X-Salesbooth-Delivery-Id
processed_at TIMESTAMPTZ DEFAULT NOW()
);
-- In your handler ($1 = req.headers['x-salesbooth-delivery-id']):
INSERT INTO processed_webhook_events (delivery_id)
VALUES ($1)
ON CONFLICT (delivery_id) DO NOTHING
RETURNING delivery_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://api.salesbooth.com/v1/webhooks/dead_letter \
-H "Authorization: Bearer $SB_API_KEY"# Retry a specific failed delivery
curl -X POST https://api.salesbooth.com/v1/webhooks/retry \
-H "Authorization: Bearer $SB_API_KEY" \
-H "Content-Type: application/json" \
-d '{"delivery_id": "whd_a1b2c3d4"}'# Replay events after a cursor
curl -X POST https://api.salesbooth.com/v1/webhooks/replay \
-H "Authorization: Bearer $SB_API_KEY" \
-H "Content-Type: application/json" \
-d '{"webhook_id":"wh_a1b2c3d4","since_sequence":1234}'Replay starts after the cursor you provide. Include since_sequence or since_timestamp to re-deliver events created after that point, optionally filtered by event type.
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://api.salesbooth.com/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