Cibotti API and webhooks
Read your ledger from your own software, and get a signed message the moment something is recorded.
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
| Request | What you get |
|---|---|
GET /api/v1/entries?after=0&limit=100 | Entries 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/projects | Every project with its task counts. |
GET /api/v1/project?id=7 | One project: tasks, milestones, evidence files (names, sizes and fingerprints, not the files) and its ledger entries. |
GET /api/v1/anchors | The hashes that were anchored to Bitcoin, with the time each was submitted (UTC). |
GET /api/v1/proof?seq=42 | The 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).
fv2 (entries written from 2026-10-07):seq|category|detail|user_id|timestamp|prev_hash|attachment_hash|property_id|project_id|origin_hash|attachment_name|attachment_type|attachment_sizefv1 or missing (older entries):seq|category|detail|user_id|timestamp|prev_hash|attachment_hash
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.
| Event | When |
|---|---|
entry.created | Any entry is written: staff entries, project actions, automatic alerts, co-signatures. |
entry.cosigned | A manager co-signs an injury entry. |
task.overdue | A project task passes its due date without being verified. |
project.closed | A project is closed. |
project.reopened | A 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
- A delivery that does not get a
2xxwithin 5 seconds is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours (6 tries in all), then dropped. Retries also happen when the next event is written and on Cibotti’s twice-daily job, so a retry can be later than these times. - The same event can arrive more than once. Use
idto ignore repeats. Events can arrive out of order; usedata.seqto order them. - Redirects are not followed. Use the final address.
- If 20 deliveries in a row fail, the webhook is switched off and shown as such under Admin. Missed entries are always still available from
/api/v1/entries. - Only public
https://addresses on the standard port are accepted (no IP numbers, nolocalhost).
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.