Webhooks push real-time event notifications to your system as HTTP POST requests. Instead of polling the API for changes, your system receives events as they happen.
Setting Up a Webhook
- Go to Settings > Webhooks in the SalesPro Hub dashboard.
- Click Add Endpoint.
- Enter:
- Name — A label for this endpoint (e.g., "Sage Order Sync").
- Endpoint URL — The HTTPS URL on your server that will receive webhook POST requests.
- Events — Select which events to subscribe to.
- Click Create.
- Copy the signing secret immediately — it starts with
whsec_and is only shown once.
Available Events
Order Events
| Event | Fires When |
|---|---|
order.submitted | A sales rep submits an order (draft → pending) |
order.confirmed | An admin confirms an order (pending → confirmed) |
order.delivered | An order is marked as delivered |
order.cancelled | An order is cancelled |
order.signed | A customer or representative adds an order signature |
Inventory Events
| Event | Fires When |
|---|---|
inventory.low_stock | A product's stock drops below its threshold |
inventory.out_of_stock | A product's stock reaches zero |
inventory.adjusted | An admin manually adjusts stock |
inventory.synced | Stock is updated via the Inventory API |
Recurring Order Events
| Event | Fires When |
|---|---|
recurring_order.created | A recurring order schedule is created |
recurring_order.executed | A recurring schedule creates an order |
Webhook Delivery
When an event fires, SalesPro Hub sends an HTTP POST request to your endpoint URL.
Request Headers
| Header | Description |
|---|---|
Content-Type | application/json |
X-Webhook-Event | Event type (e.g., order.submitted) |
X-Webhook-Id | Stable event-delivery identifier; use it as an idempotency key across retries |
X-Webhook-Attempt | Delivery attempt number, beginning at 1 |
X-Webhook-Signature | HMAC-SHA256 signature for verification |
X-Webhook-Timestamp | Unix timestamp when the webhook was sent |
User-Agent | SalesProHub-Webhook/1.0 |
Request Body
The body is a JSON object containing the event type, timestamp, and event-specific data.
Payload Examples
order.submitted
{
"event": "order.submitted",
"timestamp": "2026-02-09T08:30:00+02:00",
"data": {
"order_number": "ORD-20260209-0001",
"status": "pending",
"type": "order",
"order_date": "2026-02-09",
"delivery_date": "2026-02-16",
"customer": {
"name": "ABC Trading (Pty) Ltd",
"contact_person": "John Smith",
"email": "john@abctrading.co.za"
},
"sales_rep": {
"name": "Jane Doe"
},
"subtotal": "10000.00",
"vat_rate": "15.00",
"vat_amount": "1500.00",
"total": "11500.00",
"delivery_address": "123 Main Road, Sandton, 2196",
"notes": "Deliver before 10am",
"items": [
{
"sku": "WDG-001",
"name": "Widget A",
"quantity": 50,
"unit_price": "200.00",
"discount_percent": "0.00",
"line_total": "10000.00"
}
]
}
}order.confirmed / order.delivered / order.cancelled
Same structure as order.submitted with the status field reflecting the new status.
inventory.low_stock
{
"event": "inventory.low_stock",
"timestamp": "2026-02-09T14:00:00+02:00",
"data": {
"sku": "WDG-002",
"name": "Widget B",
"stock_qty": 8,
"reserved_qty": 3,
"available_qty": 5,
"low_stock_threshold": 10,
"previous_qty": 15,
"is_low_stock": true,
"is_out_of_stock": false,
"movement_type": "deduction",
"related_order": "ORD-20260209-0003"
}
}inventory.out_of_stock
Same structure as inventory.low_stock with is_out_of_stock: true and stock_qty: 0.
inventory.adjusted
{
"event": "inventory.adjusted",
"timestamp": "2026-02-09T09:00:00+02:00",
"data": {
"sku": "WDG-001",
"name": "Widget A",
"stock_qty": 200,
"reserved_qty": 20,
"available_qty": 180,
"low_stock_threshold": 25,
"previous_qty": 150,
"is_low_stock": false,
"is_out_of_stock": false,
"movement_type": "adjustment",
"related_order": null
}
}inventory.synced
Same structure as inventory.adjusted with movement_type: "sync". Fires when stock is updated through the Inventory API.
Signature Verification
Every webhook includes an HMAC-SHA256 signature so you can verify it came from SalesPro Hub and wasn't tampered with.
How Signing Works
- SalesPro Hub creates a signed payload:
{timestamp}.{json_body} - Signs it with your endpoint's secret using HMAC-SHA256.
- Sends the signature in the
X-Webhook-Signatureheader and the timestamp inX-Webhook-Timestamp.
Verification Steps
- Read the
X-Webhook-TimestampandX-Webhook-Signatureheaders. - Get the raw request body (before any JSON parsing).
- Create the signed payload string:
{timestamp}.{raw_body}. - Compute HMAC-SHA256 using your webhook secret.
- Compare your computed signature with the received signature.
- Optionally verify the timestamp is within 5 minutes to prevent replay attacks.
PHP Example
function verifyWebhookSignature(string $payload, string $signature, string $timestamp, string $secret): bool
{
$signedPayload = "{$timestamp}.{$payload}";
$expectedSignature = hash_hmac('sha256', $signedPayload, $secret);
if (!hash_equals($expectedSignature, $signature)) {
return false;
}
// Optional: reject webhooks older than 5 minutes
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
return true;
}
// Usage in your endpoint:
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$secret = 'whsec_your_secret_here';
if (!verifyWebhookSignature($payload, $signature, $timestamp, $secret)) {
http_response_code(401);
exit('Invalid signature');
}
$event = json_decode($payload, true);
// Process the event...Python Example
import hmac
import hashlib
import time
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = 'whsec_your_secret_here'
@app.route('/webhooks/salesrep', methods=['POST'])
def handle_webhook():
payload = request.get_data(as_text=True)
signature = request.headers.get('X-Webhook-Signature', '')
timestamp = request.headers.get('X-Webhook-Timestamp', '')
# Verify signature
signed_payload = f"{timestamp}.{payload}"
expected = hmac.new(
WEBHOOK_SECRET.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
abort(401, 'Invalid signature')
# Optional: check timestamp freshness
if abs(time.time() - int(timestamp)) > 300:
abort(401, 'Timestamp too old')
event = request.get_json()
event_type = event['event']
if event_type == 'order.submitted':
# Process new order...
pass
elif event_type == 'inventory.low_stock':
# Handle low stock alert...
pass
return '', 200Node.js Example
const crypto = require('crypto');
const express = require('express');
const app = express();
const WEBHOOK_SECRET = 'whsec_your_secret_here';
app.post('/webhooks/salesrep', express.raw({ type: 'application/json' }), (req, res) => {
const payload = req.body.toString();
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
// Verify signature
const signedPayload = `${timestamp}.${payload}`;
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
return res.status(401).send('Invalid signature');
}
// Optional: check timestamp freshness (5 minutes)
if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) {
return res.status(401).send('Timestamp too old');
}
const event = JSON.parse(payload);
console.log(`Received event: ${event.event}`);
// Process the event...
res.status(200).send('OK');
});C# Example
using System.Security.Cryptography;
using System.Text;
public bool VerifySignature(string payload, string signature, string timestamp, string secret)
{
var signedPayload = $"{timestamp}.{payload}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(signedPayload));
var expected = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(signature)
);
}Retry Policy
If your endpoint doesn't respond with a 2xx status code, SalesPro Hub retries the delivery:
| Attempt | Delay |
|---|---|
| 1st retry | 10 seconds |
| 2nd retry | 30 seconds |
| 3rd retry | 2 minutes |
| 4th retry | 5 minutes |
| 5th retry | 15 minutes |
After 5 failed attempts, the delivery is marked as permanently failed.
Auto-Disable
If an endpoint accumulates 10 consecutive failures (across any deliveries), it is automatically disabled. You'll receive an in-app notification when this happens.
To re-enable: go to Settings > Webhooks, click the toggle to enable the endpoint. This resets the failure counter.
Testing Webhooks
Click the Test button next to any endpoint to send a test webhook:
{
"event": "webhook.test",
"timestamp": "2026-02-09T12:00:00+02:00",
"data": {
"message": "This is a test webhook delivery from SalesPro Hub.",
"endpoint_name": "Sage Order Sync"
}
}Delivery History
Click View Deliveries on any endpoint to see:
- Event type
- Delivery status (success, failed, pending)
- HTTP response code
- Attempt number
- Error message (if failed)
- Timestamps
You can retry any failed delivery from this page.
Managing Endpoints
Disable/Enable
Toggle an endpoint to temporarily stop receiving webhooks without deleting it. Re-enabling resets the failure counter.
Delete
Permanently removes the endpoint and all its delivery history. Any pending retries are cancelled.
Best Practices
- Respond quickly — Return a
200 OKas soon as you receive the webhook. Process the event asynchronously if needed. - Handle duplicates — In rare cases, you may receive the same event twice. Use the
order_numberorsku+timestampto deduplicate. - Verify signatures — Always validate the HMAC signature in production.
- Use HTTPS — Webhook payloads contain business data. Only use HTTPS endpoint URLs.
- Monitor delivery history — Check for failures regularly and investigate any patterns.
- Keep your secret safe — Treat the signing secret like a password.