Webhooks
Get a signed message on your server the moment something happens in a club, and check that it came from us.
A webhook sends your server a short, signed message the moment something happens in a club: a new member, a check-in, a payment, a class change. It carries what the club’s activity log records for that change, so anything an admin can see in SettingsActivity can reach your server as it happens, instead of you polling the API.
Set one up
Open Connections
As a club admin, go to SettingsConnections and scroll to Webhooks.Add your address
Enter an https address on your server, tick the kinds of activity to send (all are ticked to start), and click Add.Copy the signing secret
It starts withwhsec_and is shown once. Your server uses it to check each delivery. Lost it? Click New secret; the old one stops at once.Send a test
Click Send test. Your server gets a signedpingevent, and the row shows what it answered.
- The address must be https with a public host name. Addresses on private networks, bare IP addresses and addresses with a password in them are refused.
- A club can have 5 webhook addresses. Each has its own secret and its own choice of activity.
- Adding, changing and removing a webhook is itself recorded in the activity log, under Settings.
What you receive
Every delivery is a POST with a JSON body and these headers:
POST /your/path HTTP/1.1
Content-Type: application/json
User-Agent: FencingOffice-Webhooks/1
Fencing-Office-Event: activity.created
Fencing-Office-Delivery: evt_Qm3c8pXb2kTt0aYw
Fencing-Office-Signature: t=1794400000,v1=5f2b9c…The body, for an entry in the activity log (activity.created):
{
"id": "evt_Qm3c8pXb2kTt0aYw",
"type": "activity.created",
"version": "2026-11-11",
"created": "2026-11-11T15:00:01.000Z",
"organizationId": "org_123",
"data": {
"activityId": "org_123_0000000042",
"at": "2026-11-11T15:00:00.000Z",
"action": "group_add_members",
"category": "groups",
"summary": "Added Ana Bell to Adults",
"ok": true,
"who": "API key for Sean Strong",
"whoKind": "api_key",
"channel": "api",
"targets": [{ "type": "members", "id": "m_9Ks1", "name": "Ana Bell" }],
"undoOf": false
}
}idis unique to the event. A retry sends the same id, so keep the ids you’ve handled and skip repeats.data.actionnames what happened, likegroup_add_members,payment.paidormember.update.data.categoryis the kind of activity you chose it by.data.okis false when someone tried and it didn’t go through; failed attempts are sent too.data.targetsnames every record it touched, by type and id. Look up the details you need with the REST API, for examplemember_getwith a member’s id.data.undoOfis true when the entry undoes an earlier one.- Events don’t carry before and after values or other details, only what you see here. That keeps phone numbers, birthdays and amounts typed into a change out of other servers. The API reads them when you need them, with the same field rules as a key.
typeispingfor the test button. Answer it like any other event.- Changes tried with a test key aren’t made, so they aren’t in the activity log and send nothing.
Kinds of activity
| Category | For example |
|---|---|
members | New members, edits, archiving, waivers |
attendance | Check-ins |
groups | People added to or taken out of a group |
billing | Charges, payments, refunds, memberships |
messages | Emails and texts sent |
classes | Classes, bookings and cancellations |
store | Products, stock and orders |
settings | Club settings, keys and webhooks |
assistant | What the assistant did |
other | Anything else |
Check the signature
The Fencing-Office-Signature header is t= the time it was sent (Unix seconds) and v1= the hex HMAC-SHA256 of the time, a dot and the raw body, keyed with your signing secret. Recompute it, compare in constant time, and refuse a time more than 5 minutes off, so an old delivery can’t be replayed.
// Node (Express). Read the raw body: the signature covers it byte for byte.
const crypto = require('crypto');
const express = require('express');
const SECRET = process.env.FENCING_OFFICE_WEBHOOK_SECRET; // whsec_...
function verify(header, body) {
const parts = Object.fromEntries((header || '').split(',').map(function (p) { return p.split('='); }));
const t = Number(parts.t);
if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > 300) return false;
const want = crypto.createHmac('sha256', SECRET).update(t + '.' + body).digest('hex');
return want.length === parts.v1.length &&
crypto.timingSafeEqual(Buffer.from(want), Buffer.from(parts.v1));
}
const app = express();
app.post('/hooks/fencing-office', express.raw({ type: 'application/json' }), function (req, res) {
const body = req.body.toString('utf8');
if (!verify(req.get('Fencing-Office-Signature'), body)) return res.sendStatus(400);
const event = JSON.parse(body);
res.sendStatus(200); // answer first, then do the work
handle(event);
});# Python (Flask). Use the raw request body, not re-encoded JSON.
import hmac, hashlib, os, time
from flask import Flask, request
SECRET = os.environ["FENCING_OFFICE_WEBHOOK_SECRET"].encode() # whsec_...
app = Flask(__name__)
def verify(header, body):
parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
t, sig = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or not sig or abs(time.time() - int(t)) > 300:
return False
want = hmac.new(SECRET, f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, sig)
@app.post("/hooks/fencing-office")
def hook():
if not verify(request.headers.get("Fencing-Office-Signature"), request.get_data()):
return "", 400
event = request.get_json()
return "", 200Delivery and retries
- Answer with any
2xxwithin 10 seconds. Do slow work after you answer. - A timeout, a network error, a
429or a5xxis tried twice more, after 1 and 4 seconds. Any other answer, including a redirect, is not retried: give the final address. - Events are sent as they happen, so two close together can arrive out of order. Use
data.atto order them. - After 25 failed deliveries in a row the webhook turns itself off, and the row says why. Fix your server, then turn it back on; the count starts again.
- Nothing is queued while a webhook is off, and a failed event isn’t sent again later. To catch up, read what you missed with
activity_search.