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

  1. Open Connections

    As a club admin, go to SettingsConnections and scroll to Webhooks.
  2. 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.
  3. Copy the signing secret

    It starts with whsec_ and is shown once. Your server uses it to check each delivery. Lost it? Click New secret; the old one stops at once.
  4. Send a test

    Click Send test. Your server gets a signed ping event, 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
  }
}
  • id is unique to the event. A retry sends the same id, so keep the ids you’ve handled and skip repeats.
  • data.action names what happened, like group_add_members, payment.paid or member.update. data.category is the kind of activity you chose it by.
  • data.ok is false when someone tried and it didn’t go through; failed attempts are sent too.
  • data.targets names every record it touched, by type and id. Look up the details you need with the REST API, for example member_get with a member’s id.
  • data.undoOf is 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.
  • type is ping for 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

CategoryFor example
membersNew members, edits, archiving, waivers
attendanceCheck-ins
groupsPeople added to or taken out of a group
billingCharges, payments, refunds, memberships
messagesEmails and texts sent
classesClasses, bookings and cancellations
storeProducts, stock and orders
settingsClub settings, keys and webhooks
assistantWhat the assistant did
otherAnything 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 "", 200

Delivery and retries

  • Answer with any 2xx within 10 seconds. Do slow work after you answer.
  • A timeout, a network error, a 429 or a 5xx is 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.at to 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.