Webhooks: events, signatures and retries
Receive signed events when sales, voids, customers and wholesale invoices happen, verify them, and handle retries.
How webhooks work
When something happens in the business, LeisureNexus sends an HTTPS POST with a JSON body to your endpoint. Each delivery is signed with your endpoint's secret, retried if your server doesn't answer, and logged so you can see and resend it.
Events
| Event | Sent when | data contains |
|---|---|---|
order.completed | A sale or refund is recorded | The sale, as in GET /orders/{id} |
order.voided | A sale is voided | The sale, with status: "voided" |
customer.created | A customer is added (register, back office or API) | The customer, as in GET /customers/{id} |
customer.updated | A customer is changed | The customer |
sales_order.invoiced | A wholesale order is invoiced | The wholesale order |
ping | You press Send test | {"message": "Hello from LeisureNexus"} |
Add an endpoint
Back office → Point of sale → Developer API → Webhooks → Add:
- Your server's address — a public HTTPS address, e.g.
https://example.com/hooks/leisurenexus. Private networks, plain HTTP and addresses that don't resolve are refused. - Send me — tick the events you want — and an optional description.
- Copy the signing secret — it starts with
whsec_and is shown once — then I've saved it. - Send test sends a
pingand shows what your server answered ("Test delivered", or the HTTP status it got).
Turn off pauses an endpoint without deleting it; Turn on resumes it; Remove deletes it.
What a delivery looks like
POST /hooks/leisurenexus HTTP/1.1
Content-Type: application/json
User-Agent: LeisureNexus-Webhooks/1
X-LeisureNexus-Event: order.completed
X-LeisureNexus-Delivery: 3c9b6f0e-2d41-4a77-9a0b-6c0f2e5d8a10
X-LeisureNexus-Signature: t=1792245900,v1=5f0c…e81a
{
"id": "3c9b6f0e-2d41-4a77-9a0b-6c0f2e5d8a10",
"event": "order.completed",
"created_at": "2026-10-17T14:05:00+00:00",
"data": { "id": "e7a9…1c33", "receipt_number": "001-01-000123", "type": "sale", "status": "completed", "total_cents": 864, "…": "…" }
}
Verify the signature
X-LeisureNexus-Signature is t=<unix time>,v1=<hex HMAC-SHA256>, where the HMAC is computed over "<t>.<raw body>" with your whsec_ secret.
- Read the raw request body — before any JSON parsing.
- Compute
HMAC-SHA256(secret, t + "." + body)and compare it withv1in constant time. - Reject deliveries whose
tis more than 5 minutes from your clock.
Node.js (Express)
import crypto from 'node:crypto'
import express from 'express'
const app = express()
const SECRET = process.env.LEISURENEXUS_WEBHOOK_SECRET // whsec_…
app.post('/hooks/leisurenexus', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-LeisureNexus-Signature') ?? ''
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
const expected = crypto.createHmac('sha256', SECRET).update(`${parts.t}.${req.body}`).digest('hex')
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300
const ok = parts.v1 && fresh && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
if (!ok) return res.status(400).send('bad signature')
const event = JSON.parse(req.body)
res.sendStatus(200) // answer first, then do the work
handle(event)
})
PHP
$secret = getenv('LEISURENEXUS_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_LEISURENEXUS_SIGNATURE'] ?? ''), $sig);
$expected = hash_hmac('sha256', ($sig['t'] ?? '') . '.' . $body, $secret);
if (!isset($sig['v1']) || abs(time() - (int) $sig['t']) > 300 || !hash_equals($expected, $sig['v1'])) {
http_response_code(400);
exit('bad signature');
}
$event = json_decode($body, true);
http_response_code(200);
Python (Flask)
import hmac, hashlib, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["LEISURENEXUS_WEBHOOK_SECRET"].encode()
@app.post("/hooks/leisurenexus")
def hook():
body = request.get_data()
sig = dict(p.split("=", 1) for p in request.headers.get("X-LeisureNexus-Signature", "").split(",") if "=" in p)
expected = hmac.new(SECRET, f"{sig.get('t')}.".encode() + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig.get("v1", "")) or abs(time.time() - int(sig.get("t", 0))) > 300:
abort(400)
event = request.get_json()
return "", 200
Answering a delivery
- Answer with any 2xx status within 10 seconds. Do slow work after answering, in a queue.
- Redirects are not followed — use the final address.
- Anything else (an error status, a timeout, no connection) counts as a failure.
Retries and switching off
| Attempt | When |
|---|---|
| 1 | Straight away |
| 2 | 1 minute later |
| 3 | 5 minutes later |
| 4 | 30 minutes later |
| 5 | 2 hours later |
| 6 | 6 hours later |
| 7 (last) | 12 hours later |
After the last attempt the delivery is marked failed. An endpoint that fails 20 times in a row is switched off, with the reason shown; fix your server and press Turn on. Recent deliveries shows each delivery's status, attempts and your server's answer, and Send again resends one.
Handle duplicates and order
A delivery can arrive more than once (for example when your server answered too slowly). Use the id in the body (also in X-LeisureNexus-Delivery) to ignore ones you've already processed. Deliveries can arrive out of order — when order matters, compare created_at, or fetch the latest state with the API.
Still stuck? Email support@leisurenexus.com or send us a message — we're happy to help.