Webhooks

A site can tell your server when a deploy is ready, when it goes live and when a visitor sends a form. Every request is signed so you can check it came from Liftpad.

Add a webhook

In the dashboard, open the site, then Notifications: pick Webhook (or Slack), paste a public https address and choose the events. The signing secret (lpwh_...) is shown once: keep it in your server's secrets. Notifications are changed only from the dashboard, never with an API token, so a leaked token cannot add a webhook that forwards future submissions. A read token can still read and export the submissions already received, so keep every token secret, read tokens too.

Events

EventWhen
deploy.readyA deploy finished and its preview works
deploy.publishedA deploy went live (also after a rollback)
form.submissionA visitor sent a form (spam is not sent)
testSent by the test button (testHook)

The request

Liftpad sends a POST with a JSON body and these headers:

content-type: application/json
user-agent: Liftpad-Notifications/1
x-liftpad-event: deploy.published
x-liftpad-delivery: 0a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d
x-liftpad-timestamp: 1793977500
x-liftpad-signature: sha256=<hex HMAC-SHA256 of "<timestamp>.<raw body>">
{
  "event": "deploy.published",
  "delivery": "0a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
  "at": "2026-11-05T15:05:00.000Z",
  "site": {
    "name": "my-site",
    "url": "https://www.example.com"
  },
  "deploy": {
    "id": "k2j4h6g8f0d2",
    "title": "First deploy"
  }
}

Check the signature

  1. Read the raw body as bytes, before any JSON parsing.
  2. Compute HMAC-SHA256 with your secret over <x-liftpad-timestamp>.<raw body> (the timestamp, a dot, then the body) and write it in lowercase hex, after sha256=.
  3. Compare it with x-liftpad-signature in constant time.
  4. Refuse timestamps more than 5 minutes away from your clock (replays). Use x-liftpad-delivery to ignore a delivery you already handled.

Node.js

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyLiftpad(rawBody, headers, secret, toleranceSeconds = 300) {
  const timestamp = String(headers["x-liftpad-timestamp"] ?? "");
  const signature = String(headers["x-liftpad-signature"] ?? "");
  if (!/^\d+$/.test(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
  const expected = "sha256=" + createHmac("sha256", secret).update(timestamp + ".").update(rawBody).digest("hex");
  const a = Buffer.from(signature);
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}
import { createServer } from "node:http";

createServer((req, res) => {
  const chunks = [];
  req.on("data", (chunk) => chunks.push(chunk));
  req.on("end", () => {
    const raw = Buffer.concat(chunks);
    if (!verifyLiftpad(raw, req.headers, process.env.LIFTPAD_WEBHOOK_SECRET)) {
      res.writeHead(401).end();
      return;
    }
    const event = JSON.parse(raw);
    console.log(event.event, event.site.name);
    res.writeHead(204).end();
  });
}).listen(3000);

Python

import hashlib
import hmac
import time


def verify_liftpad(raw_body: bytes, headers, secret: str, tolerance_seconds: int = 300) -> bool:
    timestamp = headers.get("x-liftpad-timestamp", "")
    signature = headers.get("x-liftpad-signature", "")
    if not timestamp.isdigit():
        return False
    if abs(time.time() - int(timestamp)) > tolerance_seconds:
        return False
    mac = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256)
    return hmac.compare_digest(signature, "sha256=" + mac.hexdigest())

With Flask: verify_liftpad(request.get_data(), request.headers, secret) (Flask's headers ignore case).

Delivery