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
| Event | When |
|---|---|
deploy.ready | A deploy finished and its preview works |
deploy.published | A deploy went live (also after a rollback) |
form.submission | A visitor sent a form (spam is not sent) |
test | Sent 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
- Read the raw body as bytes, before any JSON parsing.
- 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, aftersha256=. - Compare it with
x-liftpad-signaturein constant time. - 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
- Answer with any 2xx status within 10 seconds. Redirects are not followed.
- Each event is sent once, with no retry. After 50 failed deliveries in a row the notification pauses; the dashboard shows the last status.
- Spam form submissions are not sent. Files sent with a form are listed by name; download them with the API.
- Test it with the Send test button in the dashboard (event test).