Webhooks: events, signatures and retries

Receive signed events when sales, voids, customers and wholesale invoices happen, verify them, and handle retries.

9 min read Updated Oct 17, 2026

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

EventSent whendata contains
order.completedA sale or refund is recordedThe sale, as in GET /orders/{id}
order.voidedA sale is voidedThe sale, with status: "voided"
customer.createdA customer is added (register, back office or API)The customer, as in GET /customers/{id}
customer.updatedA customer is changedThe customer
sales_order.invoicedA wholesale order is invoicedThe wholesale order
pingYou press Send test{"message": "Hello from LeisureNexus"}

Add an endpoint

Back office → Point of sale → Developer API → Webhooks → Add:

  1. 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.
  2. Send me — tick the events you want — and an optional description.
  3. Copy the signing secret — it starts with whsec_ and is shown once — then I've saved it.
  4. Send test sends a ping and 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.

  1. Read the raw request body — before any JSON parsing.
  2. Compute HMAC-SHA256(secret, t + "." + body) and compare it with v1 in constant time.
  3. Reject deliveries whose t is 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

AttemptWhen
1Straight away
21 minute later
35 minutes later
430 minutes later
52 hours later
66 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.