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

EventWhen it fires
signing.completedA waiver is signed
signing.reattestedSomeone checks in or re-confirms their waiver
signing.acceptedA signing is accepted
signing.rejectedA signing is rejected
signing.taggedA signing's tags change
customer.createdA new customer is added
customer.updatedA customer's details change
signing_link.openedSomeone 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": "Cedar Creek Range" },
  "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 "", 200

Delivery 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 id to handle it once.
  • To replace a secret, rotate it with POST /v1/webhooks/{id}/rotate-secret.