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.