Webhooks

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" } }
Store your signing secret securely

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}
Process asynchronously

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 format v1=<hex>
  • X-Salesbooth-Timestamp — Unix timestamp in seconds (prevents replay attacks)
Always verify signatures

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

Event typeWhen firedKey data fields
deal.createdDeal submitted by customerdeal_id, customer_id, total, currency, status
deal.updatedDeal fields modifieddeal_id, changes, updated_by
deal.status_changedStatus transitions (e.g. pending → won)deal_id, from_status, to_status
deal.closedDeal closed by seller or customerdeal_id, reason, cancelled_by
deal.fulfilledDeal marked as fulfilleddeal_id, fulfilled_at
deal.expiredDeal passed its expiry date without acceptancedeal_id, expired_at
deal.unsignedDeal reverted to unsigned statedeal_id
deal.signature_addedA party signed the dealdeal_id, signed_at, signer_email
deal.signature_timeoutSigning deadline passed without all signaturesdeal_id, deadline
deal.item_addedProduct line item added to dealdeal_id, item_id, product_id, quantity
deal.item_removedLine item removed from dealdeal_id, item_id
deal.item_updatedLine item quantity or price changeddeal_id, item_id, changes
deal.discount_appliedDiscount code or manual discount applieddeal_id, discount_amount, discount_type
deal.partially_acceptedDeal accepted with subset of itemsdeal_id, accepted_items
deal.created_configuredDeal created from a saved configurationdeal_id, saved_config_id
deal.negotiation_proposedCustomer proposed a counter-offerdeal_id, proposed_total, terms
deal.negotiation_counter_proposedSeller issued a counter-proposaldeal_id, proposed_total
deal.negotiation_acceptedNegotiation accepted by either partydeal_id, final_total
deal.negotiation_rejectedNegotiation rejecteddeal_id, rejected_by

Deal settlement events

Event typeWhen firedKey data fields
deal.settlement_createdSettlement record created for a dealdeal_id, settlement_id
deal.settlement_initiatedSettlement payout process starteddeal_id, settlement_id, amount
deal.settlement_completedSettlement payout completed successfullydeal_id, settlement_id, amount, currency
deal.settlement_failedSettlement payout faileddeal_id, settlement_id, reason
deal.settlement_all_completedAll parties in a multi-party deal settleddeal_id

Deal participant events

Event typeWhen firedKey data fields
deal.participant_invitedParty invited to join a multi-party dealdeal_id, participant_id, email
deal.participant_acceptedInvited participant accepteddeal_id, participant_id
deal.participant_withdrawnParticipant withdrew from dealdeal_id, participant_id
deal.participant_completedParticipant fulfilled their obligationsdeal_id, participant_id

Payment events

Event typeWhen firedKey data fields
payment.receivedPayment successfully captureddeal_id, amount, currency, payment_method
deal.payment_receivedPayment received against a dealdeal_id, amount, currency
deal.payment_failedPayment capture faileddeal_id, failure_code, failure_message
deal.payment_refundedRefund issueddeal_id, refund_amount, reason
deal.payment_overduePayment past its due datedeal_id, due_date, amount

Negotiation events

Event typeWhen fired
negotiation.proposedNew negotiation proposal submitted
negotiation.counteredCounter-proposal issued
negotiation.acceptedNegotiation terms accepted
negotiation.rejectedNegotiation proposal rejected
negotiation.expiredNegotiation timed out without resolution

Contract events

Event typeWhen fired
contract.signedContract fully signed by all required parties
contract.activatedContract entered active state
contract.terminatedContract terminated before natural end
contract.tamper_detectedContract content hash mismatch detected

Subscription events

Event typeWhen fired
subscription.createdNew subscription started
subscription.renewedBilling cycle renewed
subscription.renewal_upcomingRenewal reminder (configurable days before)
subscription.pausedSubscription paused
subscription.resumedPaused subscription resumed
subscription.cancelledSubscription cancelled
subscription.upgradedPlan upgraded to higher tier
subscription.downgradedPlan downgraded to lower tier
subscription.changedSubscription terms changed
subscription.past_duePayment past due, subscription at risk
subscription.suspendedSubscription suspended due to non-payment
subscription.cycle_changedBilling cycle period changed
subscription.payment_failedSubscription payment failed
subscription.payment_retriedFailed payment retried
subscription.proration_creditProration credit issued on plan change
subscription.meter_addedUsage meter added to subscription
subscription.meter_removedUsage meter removed from subscription
subscription.usage_recordedMetered usage event recorded
subscription.usage_charges_appliedMetered usage charges billed

Customer events

Event typeWhen fired
customer.createdNew customer record created
customer.updatedCustomer data updated
customer.deletedCustomer record deleted or GDPR-erased

Booking events

Event typeWhen fired
booking.createdNew booking confirmed
booking.cancelledBooking cancelled by customer or staff
booking.completedAppointment completed successfully
booking.no_showCustomer did not attend the booking
booking.heldBooking placed on hold
booking.hold_expiredBooking hold expired without confirmation

Escrow events

Event typeWhen fired
escrow.createdEscrow hold created for a deal
escrow.releasedEscrow funds released to seller
escrow.refundedEscrow funds returned to buyer
escrow.condition_fulfilledRelease condition met
escrow.reauthorization_pendingCard reauthorization required before expiry
escrow.reauthorizedEscrow successfully reauthorized
escrow.reauthorization_failedReauthorization attempt failed

Saved configuration events

Event typeWhen fired
saved_config.createdCustomer saved a product configuration
saved_config.viewedSaved configuration link opened
saved_config.convertedSaved configuration resulted in a deal
saved_config.expiringSaved configuration approaching expiry
saved_config.expiredSaved configuration expired

Delegation events

Event typeWhen fired
delegation.proposedDelegation proposal sent to another party
delegation.acceptedDelegation proposal accepted
delegation.rejectedDelegation proposal rejected
delegation.counteredCounter-proposal to a delegation submitted
delegation.grantedDelegation authority granted
delegation.revokedDelegation authority revoked
delegation.expiredDelegation expired without acceptance
delegation.budget_warningDelegated spending approaching budget limit

Agent & workflow events

Event typeWhen fired
agent.workflow.plannedWorkflow execution plan created
agent.workflow.completedWorkflow finished successfully
agent.workflow.failedWorkflow terminated with an error
agent.workflow.step_completedIndividual workflow step completed
agent.workflow.step_retriedWorkflow step retried after failure
agent.workflow.dead_letteredWorkflow moved to dead-letter queue
agent.workflow.cancelledWorkflow cancelled by operator
agent.workflow.degradedWorkflow running in degraded mode
agent.workflow.approvedHuman approval gate passed
agent.approval_requiredWorkflow paused awaiting human approval
agent.approval_resentApproval request resent
agent.approval_escalatedApproval escalated to higher authority
agent.trust.level_changedAgent trust level changed
agent.trust.abuse_detectedAbuse pattern detected from agent
agent.trust.decay_warningAgent trust score decaying
agent.trust_cap_exceededAgent spending cap exceeded
agent.anomaly_detectedAnomalous agent behaviour detected
agent.spending_ceilingAgent reached spending ceiling
workflow.sell.proposal_receivedAgent-initiated sell workflow received a proposal
workflow.fulfill.deliveredAgent fulfilment workflow delivered

Product catalog events

Event typeWhen fired
product_family.createdNew product family created
product_family.updatedProduct family updated
product_family.deletedProduct family deleted
option_group.createdOption group created
option_group.updatedOption group updated
option_group.deletedOption group deleted
option.createdOption created within a group
option.updatedOption updated
option.deletedOption deleted
compatibility_rule.createdCompatibility rule created
compatibility_rule.updatedCompatibility rule updated
compatibility_rule.deletedCompatibility rule deleted
bundle_rule.createdBundle rule created
bundle_rule.updatedBundle rule updated
bundle_rule.deletedBundle rule deleted

Billing events

Event typeWhen fired
billing.low_balanceAccount credit balance below threshold
billing.credit_addedCredits added to account
billing.credit_deductedCredits deducted from account
billing.auto_topupAutomatic top-up triggered
billing.widget_degradedWidget entering degraded mode due to low balance

Trust events

Event typeWhen fired
tenant.trust_level_changedTenant trust level changed
tenant.trust_promotedTenant promoted to higher trust tier
tenant.trust_level_decay_warningTenant trust score decaying
trust.demotedEntity demoted to lower trust level
trust.fraud_signalFraud signal recorded against entity

System events

Event typeWhen fired
credential.issuedVerifiable credential issued
api_key.rotatedAPI key rotated
encryption.rekey_completedEncryption rekey operation completed
consent.expiring_soonCustomer consent approaching expiry
consent.expiredCustomer consent expired
job.completedBackground job completed successfully
job.failedBackground job failed
queue.depth_alertJob queue depth exceeded alert threshold
system.job_dead_letterJob moved to dead-letter queue
service.circuit_openedCircuit breaker opened for a downstream service
service.circuit_closedCircuit breaker closed — service recovered
circuit-breaker.backoff-increasedCircuit breaker backoff interval increased
security.csp_spikeUnusual spike in CSP violation reports

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

1
Initial attempt
T+0
2
First retry
T+5 minutes
3
Final retry → Dead letter
T+30 minutes

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 });
Use persistent storage for deduplication

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 it

Dead 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"}'
Set up delivery alerts

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 → Webhooks

Verify 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

Next Steps