Integrations

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
  • t is when the message was sent, in Unix seconds. v1 is 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 v1 using a constant-time comparison. Reject the message if t is 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)
Check the raw body, before parsing it. If your framework parses the JSON and you turn it back into text, the spacing and key order change. The signature will then never match. Read the body as bytes first, check it, then parse. For the same reason, do not skip the timestamp check. It stops someone who captured an old message from sending it to you again.

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.

Not receiving events? Check the endpoint's status on Settings → Webhooks. If it shows as paused after repeated failures, your server could not be reached or was returning errors. Once it works again, turn the endpoint back on with Edit. Its failure count then starts again from zero. Events from while it was paused are not sent and are not in its delivery list, so there is nothing to send again for them. "Send again" still works on the failed deliveries from before the pause.