Developers
Webhooks
Get a message the moment something happens, instead of polling. Each event is a signed POST with a JSON body.
Add an endpoint
Add an endpoint in Settings, Integrations, or with POST /v1/webhooks. The address must be https. The signing secret (whsec_...) is shown once when you add it. A key needs the webhooks:manage permission to manage endpoints through the API.
Events
| Event | When it fires |
|---|---|
signing.completed | A waiver is signed |
signing.reattested | Someone checks in or re-confirms their waiver |
signing.accepted | A signing is accepted |
signing.rejected | A signing is rejected |
signing.tagged | A signing's tags change |
customer.created | A new customer is added |
customer.updated | A customer's details change |
signing_link.opened | Someone opens a personal signing link made through the API |
Sending a test from Settings delivers a webhook.test event. This is the shape of a delivery:
{
"id": "evt_6f1c...",
"object": "event",
"type": "signing.completed",
"created": "2026-10-27T15:04:05.000Z",
"business": { "id": "...", "name": "Harbor Yoga Studio" },
"data": {
"signing": {
"id": "...", "object": "signing",
"template": { "id": "...", "title": "Day pass", "version": 3 },
"signer": { "first_name": "Dana", "last_name": "Reyes", "email": "...", "phone": "..." },
"customer_id": "...", "external_reference": "sale-1042"
}
}
}If you attached an external_reference when you created the signing link, it comes back here so you can match the event to your own record.
Verify the signature
Every request carries X-Waiver-Signature: t=...,v1=.... v1 is the HMAC-SHA256 of t, a full stop and the raw body, keyed with the endpoint's signing secret. Check it before you trust the body, and refuse a t more than five minutes old.
Node
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const SECRET = process.env.WAIVER_WEBHOOK_SECRET; // whsec_...
const app = express();
// Verify against the raw bytes, before any JSON parsing.
app.post("/hooks/waivers", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-Waiver-Signature") ?? "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = createHmac("sha256", SECRET)
.update(`${parts.t}.${req.body.toString("utf8")}`)
.digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
const ok =
fresh &&
typeof parts.v1 === "string" &&
parts.v1.length === expected.length &&
timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
if (!ok) return res.status(400).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
// Handle each event once: deliveries can repeat, so remember event.id.
console.log(event.type, event.data);
res.sendStatus(200);
});Python
import hashlib, hmac, json, os, time
from flask import Flask, request, abort
SECRET = os.environ["WAIVER_WEBHOOK_SECRET"].encode() # whsec_...
app = Flask(__name__)
@app.post("/hooks/waivers")
def waivers():
header = request.headers.get("X-Waiver-Signature", "")
parts = dict(item.split("=", 1) for item in header.split(",") if "=" in item)
body = request.get_data() # the raw bytes, exactly as sent
signed = parts.get("t", "").encode() + b"." + body
expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
fresh = abs(time.time() - int(parts.get("t", "0"))) < 300
if not fresh or not hmac.compare_digest(expected, parts.get("v1", "")):
abort(400)
event = json.loads(body)
# Handle each event once: deliveries can repeat, so remember event["id"].
print(event["type"], event["data"])
return "", 200Delivery and retries
- Answer with any 2xx within 10 seconds. Redirects are not followed.
- A failed delivery is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours.
- If every delivery to an endpoint fails for a whole day, it is turned off and the owner is emailed. Turn it back on in Settings, Integrations.
- The delivery log keeps 30 days, and any delivery can be sent again.
- Because of retries the same event can arrive twice. Use its
idto handle it once. - To replace a secret, rotate it with
POST /v1/webhooks/{id}/rotate-secret.