← cibotti.appPublic verification

Cibotti API and webhooks

Read your ledger from your own software, and get a signed message the moment something is recorded.

Read-only. A key can read your account’s entries, projects, anchors and proofs. It cannot create, change or delete anything, and it never receives attached files or where and on what device people worked (IP addresses, places, browsers). Cibotti sets up keys and webhooks for your account: email john@cibotti.app with what you want to connect.

Getting started

Base address: https://cibotti-ledger-api.cibotti-com.workers.dev

Send your key in the Authorization header. It starts with cbt_.

curl -H "Authorization: Bearer cbt_YOUR_KEY" \
  "https://cibotti-ledger-api.cibotti-com.workers.dev/api/v1/entries?limit=100"

Each key can make about 120 requests a minute; after that the answer is 429 with a Retry-After header. A wrong, revoked or missing key gets 401. Keep keys out of web pages and source code: anyone who has one can read your ledger.

Endpoints

RequestWhat you get
GET /api/v1/entries?after=0&limit=100Entries in order, newest last. limit is 1–500 (default 100). The answer has has_more and next_after: pass next_after as after to get the next page.
GET /api/v1/projectsEvery project with its task counts.
GET /api/v1/project?id=7One project: tasks, milestones, evidence files (names, sizes and fingerprints, not the files) and its ledger entries.
GET /api/v1/anchorsThe hashes that were anchored to Bitcoin, with the time each was submitted (UTC).
GET /api/v1/proof?seq=42The OpenTimestamps proof (base64) covering entry 42, whether it is confirmed in a Bitcoin block yet, and which block.
GET /api/verify?hash=…No key needed. Whether a hash is in the ledger, when it was recorded and whether it has been anchored.

An entry

{
  "seq": 42, "timestamp": "2026-10-07T18:22:41.915Z", "category": "inspection",
  "user_id": "u-gm", "user_name": "Gina Manager", "detail": "Front gate inspected and locked.",
  "prev_hash": "…", "hash": "…", "fv": 2,
  "attachment_hash": null, "attachment_name": null, "attachment_type": null, "attachment_size": null,
  "property_id": "your-account", "project_id": null,
  "origin_hash": "…",
  "status": "confirmed", "cosigner_id": null, "cosigned_at": null, "system_generated": 0
}

Checking the hashes yourself

You do not have to trust what we send: every entry carries what is needed to recompute its hash with SHA-256 (lower-case hex).

Empty or missing values are empty strings. prev_hash is the previous entry’s hash (empty for entry 1). origin_hash is a fingerprint of where and on what device the entry was made; the details themselves (IP address, place, browser) are not shared through the API, but they are locked into the hash, so a change to them would still break it. The attachment_hash of a file is the SHA-256 of the file’s bytes.

// Node.js
import { createHash } from 'node:crypto';
const sha = t => createHash('sha256').update(t).digest('hex');
const v = x => x === null || x === undefined ? '' : String(x);
function entryHash(e, prev) {
  if (e.fv === 2) return sha([e.seq, e.category, e.detail, e.user_id, e.timestamp, prev, e.attachment_hash, e.property_id,
    e.project_id, e.origin_hash, e.attachment_name, e.attachment_type, e.attachment_size].map(v).join('|'));
  return sha(`${e.seq}|${e.category}|${e.detail}|${e.user_id}|${e.timestamp}|${prev}|${e.attachment_hash || ''}`);
}
// walk the pages from entry 1, rebuilding each hash on top of the REBUILT one before it:
let prev = '';
for (const e of entries) { const h = entryHash(e, prev); if (h !== e.hash) console.log('mismatch at', e.seq); prev = h; }

To go further than us, take an anchored hash from /api/v1/anchors, fetch its proof from /api/v1/proof, and check it with the open-source OpenTimestamps tools and a Bitcoin node of your own.

Webhooks

Give Cibotti an https:// address and the events you want, and we set it up for you. When one happens, Cibotti sends a POST with a JSON body. Answer with any 2xx status to say you got it.

EventWhen
entry.createdAny entry is written: staff entries, project actions, automatic alerts, co-signatures.
entry.cosignedA manager co-signs an injury entry.
task.overdueA project task passes its due date without being verified.
project.closedA project is closed.
project.reopenedA closed project is reopened.
{
  "id": "evt_9f1c2a7b04d3e8a15b6c7d90",
  "type": "entry.created",
  "created_at": "2026-10-07T18:22:42.101Z",
  "property_id": "your-account",
  "data": { "seq": 42, "category": "inspection", "timestamp": "2026-10-07T18:22:41.915Z",
            "user_id": "u-gm", "project_id": null, "hash": "…", "detail": "Front gate inspected and locked." }
}

Headers: Cibotti-Event (the type), Cibotti-Signature, Content-Type: application/json, User-Agent: Cibotti-Webhooks/1.

Checking the signature

Cibotti-Signature looks like t=1790000000,v1=3f5a…. v1 is the HMAC-SHA256, with your webhook’s secret (whsec_…), of the text t + "." + the raw request body. Check it against the raw bytes you received, before parsing the JSON, and reject deliveries whose t is more than a few minutes old.

// Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
function genuine(rawBody, header, secret) {
  const [t, v1] = header.split(',').map(p => p.split('=')[1]);
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const want = createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex');
  return v1.length === want.length && timingSafeEqual(Buffer.from(v1), Buffer.from(want));
}
# Python
import hmac, hashlib, time
def genuine(raw_body: bytes, header: str, secret: str) -> bool:
    t, v1 = [p.split('=', 1)[1] for p in header.split(',')]
    if abs(time.time() - int(t)) > 300: return False
    want = hmac.new(secret.encode(), t.encode() + b'.' + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(v1, want)

What happens when your server is down

Webhooks tell you something happened; they are not a substitute for reading the ledger. If you need every entry exactly once, read /api/v1/entries with a cursor and use webhooks only as a hint to read sooner.

Questions

Email john@cibotti.app.