@noggn/checklists
v0.3.2
Published
Official TypeScript SDK for the Checklist-as-a-Service API
Readme
@noggn/checklists
Official TypeScript SDK for the Checklist-as-a-Service API. Zero runtime dependencies — native fetch.
Install
npm install @noggn/checklistsSetup
Create a .env in your project root (copy .env.example):
CHECKLIST_API_KEY=sk_your_key_hereLoad it — no extra dependency needed:
- Node 20.6+: run with the native flag —
node --env-file=.env your-app.js - Next.js / Vite / Remix:
.envis loaded automatically.
The SDK reads CHECKLIST_API_KEY from the environment. CHECKLIST_BASE_URL is optional and defaults to production (https://headless-api.vercel.app).
Usage
import Checklist from "@noggn/checklists";
const client = new Checklist(); // reads CHECKLIST_API_KEY from env
// or pass it explicitly: new Checklist({ apiKey: 'sk_...' })
const tmpl = await client.templates.create({title: "Onboarding"});
const run = await client.assignments.start({checklist_id: tmpl.id, name: "Store #12"});Resources: templates, checklists, assignments, attachments, reviews, reports, events, webhooks.
Server-side only for now — the API rejects publishable (
pk_) keys, so don't ship a secret key to the browser.
HTTP clients (SDK vs fetch vs axios)
The SDK is recommended — it handles auth, Checklist-Version, auto-Idempotency-Key on POST, retries, and typed errors. If you prefer raw HTTP:
Native fetch:
const base = process.env.CHECKLIST_BASE_URL ?? "https://headless-api.vercel.app";
const headers = {
Authorization: `Bearer ${process.env.CHECKLIST_API_KEY}`,
"Checklist-Version": "2026-06-24",
"Content-Type": "application/json",
};
const res = await fetch(`${base}/v1/checklists`, {
method: "POST",
headers: {...headers, "Idempotency-Key": crypto.randomUUID()},
body: JSON.stringify({title: "Store Open"}),
});
if (!res.ok) throw new Error(await res.text());
const checklist = await res.json();axios:
import axios from "axios";
const api = axios.create({
baseURL: process.env.CHECKLIST_BASE_URL ?? "https://headless-api.vercel.app",
headers: {
Authorization: `Bearer ${process.env.CHECKLIST_API_KEY}`,
"Checklist-Version": "2026-06-24",
"Content-Type": "application/json",
},
});
const {data: checklist} = await api.post("/v1/checklists", {title: "Store Open"}, {headers: {"Idempotency-Key": crypto.randomUUID()}});Bodyless state actions (POST …/complete, …/submit) omit Content-Type and body. Lists paginate with ?limit=&starting_after=.
Verifying webhooks
When you register a webhook endpoint, events are delivered as a signed POST. The
Checklist-Signature header is t=<unix>,v1=<hex>, where v1 is
HMAC_SHA256(secret, "<t>.<rawBody>") in lowercase hex.
Use client.webhooks.constructEvent(rawBody, signatureHeader, secret) to verify and parse in one
step. It recomputes the HMAC, compares it in constant time, and rejects anything older than 300s
(replay guard). On any failure — malformed header, signature mismatch, wrong secret, or stale
timestamp — it throws WebhookSignatureError. On success it returns the typed Event.
Pass the raw request body bytes, not a re-stringified object —
JSON.stringifyof a parsed body can reorder keys and break the signature.
Express:
import express from "express";
import Checklist, {WebhookSignatureError} from "@noggn/checklists";
const client = new Checklist();
const app = express();
// express.raw gives us the exact bytes that were signed
app.post("/webhooks/checklist", express.raw({type: "application/json"}), (req, res) => {
try {
const event = client.webhooks.constructEvent(
req.body.toString("utf8"),
req.header("Checklist-Signature") ?? "",
process.env.CHECKLIST_WEBHOOK_SECRET!,
);
// event is a typed Event — handle it
if (event.action === "checklist.completed") {
// …
}
res.sendStatus(200);
} catch (err) {
if (err instanceof WebhookSignatureError) return res.sendStatus(400);
throw err;
}
});Next.js (App Router):
import Checklist, {WebhookSignatureError} from "@noggn/checklists";
const client = new Checklist();
export async function POST(req: Request) {
const rawBody = await req.text(); // raw bytes, not req.json()
try {
const event = client.webhooks.constructEvent(rawBody, req.headers.get("Checklist-Signature") ?? "", process.env.CHECKLIST_WEBHOOK_SECRET!);
// …handle event.action…
return new Response(null, {status: 200});
} catch (err) {
if (err instanceof WebhookSignatureError) return new Response("bad signature", {status: 400});
throw err;
}
}Handling deliveries: at-least-once
Delivery is at-least-once, so the same event will occasionally arrive more than once — a
retry, a crash between your 2xx and the server recording it, or a manual Resend/Replay from the
portal all re-deliver. Always dedupe by event.id before doing side effects, and make the check
durable (survives restarts, shared across instances) — an in-memory Set only protects a single
process. Ordering is best-effort; if you need order, sort by event.seq.
The robust pattern is a unique constraint on the event id — let the database reject the duplicate:
// once: create table processed_events (event_id text primary key, seen_at timestamptz default now());
const event = client.webhooks.constructEvent(rawBody, sig, secret);
const inserted = await db.query("insert into processed_events (event_id) values ($1) on conflict do nothing returning event_id", [event.id]);
if (inserted.rowCount === 0) return res.sendStatus(200); // already handled — ack and skip
await handle(event); // your side effects, exactly once
res.sendStatus(200);For a minimal local reference (in-memory, dev only) see
packages/webhook-receiver — its idempotency.ts + handler show the same
flow. Swap the Set for a durable store in production.
Manual verification (no SDK)
import {createHmac, timingSafeEqual} from "node:crypto";
function constructEvent(rawBody, signatureHeader, secret) {
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec((signatureHeader ?? "").trim());
if (!m) throw new Error("Malformed Checklist-Signature header");
const t = Number(m[1]);
const v1 = m[2];
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
if (!timingSafeEqual(Buffer.from(v1, "utf8"), Buffer.from(expected, "utf8"))) {
throw new Error("Signature mismatch");
}
if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) throw new Error("Replay guard");
return JSON.parse(rawBody);
}Full details (Hono handler, follow-up /v1 calls with fetch/axios): docs/WEBHOOKS.md.
Using with AI agents
This package ships llms.txt — a compact, agent-oriented integration guide (the same one served live at GET /llms.txt). Point your coding agent at it to learn the core model, the auth/versioning/idempotency/pagination conventions, and common recipes. The full machine-readable contract is GET /openapi.json (OpenAPI 3.1), interactive at GET /docs.
- Compose checklists from templates — fetch a template tree with
client.templates.retrieve(id), then create a checklist with an inlinegroupsarray that includes those copied groups alongside new ones, tagging each copied groupmetadata: { from_template: '<tmpl_id>' }for provenance. It's a copy/snapshot — later edits to the template don't affect the checklist. Usesource_template_idinstead when seeding from a single whole template. - Build a chatbot on Checklists — drop-in AI SDK v6 tools live at
@noggn/checklists/tools(checklistTools(client)), and a ready-made agent at@noggn/checklists/agent(createChecklistAgent, pluschecklistAgentToolto delegate to it as one tool). For the full 52-operation surface in Claude Desktop / Cursor / an MCP client, use@noggn/mcp(npx @noggn/mcp). - MCP server (
@noggn/mcp) and the HITL chat agent (@noggn/agent,POST /v1/agent/chat) both build on this SDK — see theirAGENTS.mdfor the tool surface and approval flow.
