Webhooks
Set a collection’s destination in the app, or with set_webhook, and every change is POSTed to it as JSON, signed.
records.changed
Entries written or deleted directly: by a person in the app, or by an assistant with upsert_records or delete_records. A write that changed nothing sends nothing.
{
"event": "records.changed",
"typesheet": {
"id": "5f0c…",
"name": "Sales leads"
},
"changed_by": "assistant",
"created": [
{
"id": "9a1e…",
"values": {
"company": "Acme Freight",
"stage": "Won",
"seats": 12
}
}
],
"updated": [],
"deleted": [
"3b7d…"
]
}import.ready
A file has been checked and its items have joined the collection.
{
"event": "import.ready",
"typesheet": {
"id": "5f0c…",
"name": "Sales leads"
},
"import": {
"id": "c42f…",
"source_name": "leads-sept.csv",
"guest": null,
"row_count": 1,
"created_at": "2026-09-25T10:00:00.000Z"
},
"records": [
{
"company": "Globex",
"stage": "Open",
"seats": 40
}
]
}Verifying a delivery
Typesheet-Signature carries a timestamp and an HMAC-SHA256 of timestamp.bodyunder the collection’s signing secret. Reject a timestamp more than a few minutes old, and compare in constant time.
import { createHmac, timingSafeEqual } from "node:crypto"
export function verify(rawBody: string, header: string, secret: string): boolean {
const parts = new Map(header.split(",").map((p) => p.trim().split("=")))
const t = Number(parts.get("t"))
const v1 = parts.get("v1")
if (!Number.isFinite(t) || !v1) return false
// Refuse a replay of a captured request.
if (Math.abs(Date.now() / 1000 - t) > 300) return false
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest()
const got = Buffer.from(v1, "hex")
return expected.length === got.length && timingSafeEqual(expected, got)
}Retries
Up to four attempts over about thirty seconds, all with the same Typesheet-Delivery id, so make your handler idempotent on it. A 4xx other than 408 or 429 is taken as a refusal and not retried. Answer 2xx once you have the payload and do your work afterwards.