Outbound webhooks
Webhooks
A webhook sends a message to another system each time something happens in your studio, such as a new booking. Your web person can use it to update a spreadsheet, post in a Slack channel or keep another tool up to date. You need this only if someone is connecting another system by hand. If you use Zapier, it makes its own webhooks for you. FullClass sends each message as signed JSON to a web address you choose, so your system does not have to keep asking the API.
Set them up under Settings → Webhooks. Only the owner can
open it. Add an endpoint (the address that receives the messages) by giving
it a name, an https:// address that can be reached from the
internet, and the events to send it. You can add up to 10 endpoints, and
each one can get a different set of events.
One setting on that form is off unless you turn it on.
Include the gift card code needs a real decision. When
someone buys a gift card or a class pass, the
payment.succeeded event always says the sale happened, who
bought it, who holds it and for how much. The gift card code is
different, because anyone who has it can spend the card at checkout, just
like the buyer. Turn it on only for an endpoint you trust to keep it safe,
and only if your system really needs the code, for example to email it on
yourself. Turning it off again stops it straight away.
| Event | Sent when |
|---|---|
booking.created |
A booking is confirmed. An unpaid booking that is never completed
does not send one. A course sends one per session, and every session
from the same purchase has the same course_purchase_id.
|
booking.updated |
A booking is moved to another class or edited. The message lists what changed. |
booking.canceled |
A booking is canceled, by you or by the customer. |
event.created |
A class is added to your schedule. A repeating series sends one per date, and a course sends one per session. |
event.updated |
You change a class's time, location, instructor, price or size. The message lists what changed. A time change also includes the old time. |
event.canceled |
You cancel a whole class. The message says how many bookings were canceled, so you can tell it apart from customers canceling one by one. |
customer.created |
A customer account exists for the first time, whether they booked, signed up themselves or you added them. |
payment.succeeded |
A payment is taken, including payments you record by hand. A gift card or class pass sale also names the card, its type and the person who holds it. So you can send a welcome or reward email without looking anything up. |
payment.refunded |
Money goes back to a customer. This covers a cancellation with a refund, a refund you make in Stripe or Square, and a chargeback you lose. |
waitlist.offered |
A free spot in a class or course is offered to the next person on its waitlist. |
Booking and payment messages include the customer's name and email address. So you can message someone or add them to a list without looking them up first. If the customer's record has since been deleted, those details are left out, but the message is still sent.
When you add an endpoint, FullClass shows its signing secret once. Every message is signed with it, so your system can check the message really came from FullClass. Copy it straight into your endpoint's settings. If you lose it, use Rotate secret on the endpoint's page to get a new one. The old secret stops working the moment the new one appears, so update your endpoint right away.
Verifying the signature
This part is for your web person. It shows how their code checks that a
message came from FullClass. The signing secret is not a password, and
FullClass never sends it. Do not look for it in the request or compare it
with anything. Instead, every message has a
FullClass-Signature header. Your code works it out again from
the secret and the message body:
FullClass-Signature: t=1767225600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
-
tis when the message was sent, in Unix seconds.v1is an HMAC-SHA256, written as lowercase hex. -
The HMAC is worked out over the timestamp, a full stop, and the raw
request body, as in
<t>.<body>. -
The key is the whole secret exactly as shown,
whsec_prefix included, used as plain text. Do not base64-decode it. -
Compare your result with
v1using a constant-time comparison. Reject the message iftis more than 5 minutes away from your own clock, in either direction.
In Node.js with Express:
const crypto = require("crypto");
const express = require("express");
const secret = process.env.FULLCLASS_WEBHOOK_SECRET; // "whsec_..."
const app = express();
// express.raw keeps the exact bytes FullClass signed.
app.post("/fullclass", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.get("FullClass-Signature") || "", req.body)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body);
// ... handle event ...
res.sendStatus(200);
});
function verify(header, rawBody) {
if (!Buffer.isBuffer(rawBody)) return false; // a JSON parser ran first
const parts = Object.fromEntries(
header.split(",").map((p) => p.trim().split("=", 2))
);
const t = Number(parts.t);
if (!Number.isInteger(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(parts.t + ".")
.update(rawBody)
.digest();
const given = Buffer.from(parts.v1, "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
Mount this route before any app.use(express.json()). A JSON
body parser that runs first replaces the raw bytes with a parsed object, and
then the signature can never match. Treat a missing header as a failed
check. Never write the secret or the header to your logs.
In Python with Flask:
import hashlib, hmac, os, time
from flask import Flask, abort, request
SECRET = os.environ["FULLCLASS_WEBHOOK_SECRET"] # "whsec_..."
app = Flask(__name__)
@app.post("/fullclass")
def fullclass():
raw = request.get_data() # the exact bytes FullClass signed
if not verify(request.headers.get("FullClass-Signature", ""), raw):
abort(400)
event = request.get_json()
# ... handle event ...
return "", 200
def verify(header: str, raw_body: bytes) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or not v1:
return False
if abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(
SECRET.encode(), t.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, v1)
Each message also has FullClass-Event-Id,
FullClass-Event-Type and FullClass-Delivery-Id. A
retry is signed again with a new timestamp, so its signature differs from
the first attempt. Use FullClass-Event-Id to spot an event you
have already handled. Endpoints that an app such as Zapier adds for you are
signed too, but their secret is never shown. So you can only check the
signature on endpoints you add yourself.
Each endpoint's page shows its recent deliveries. You see the event, when it was sent, what your server answered and how many tries were made. A failed delivery is tried again by itself, with longer gaps each time. You can also open a delivery's message, or send it again once your endpoint is fixed. An endpoint that keeps failing is paused by itself, so a dead server does not build up a backlog. Fix the receiving end, then edit the endpoint and turn it back on.