API guides

Verified history & export

Prove a document's past — to yourself, or to someone who doesn't trust you yet.

The API isn’t switched on in this environment yet. Everything below is accurate; the examples come from the published contract rather than a live read.

Verified history — coming

The provenance family is in the contract and not switched on here yet — the endpoints answer not_enabled until it is. The contract they keep:

History, verified on every read

Every state the document has been through, in order — who acted (from your side's point of view), when, and a verified boolean the graph recomputes from its own hash chain on every read. Attribution rides the entry's note verbatim: a transition made through the API reads via API (rk_…), one Gio carried out for a user reads via Gio.

A document's history
From the published contract
curl "https://api.rivet.network/v1/documents/{documentId}/history" \
  -H "Authorization: Bearer rk_live_…"

A document adopted mid-life carries a baseline marker: what happened before Rivet witnessed it is named as unwitnessed — never backfilled, never dressed up.

Lineage, with sealed steps

direction=up (the default) is the documents this one came from; down is what came of it. A step on the counterparty's own side appears as a sealed node — {sealed, transition_hash, occurred_at} and nothing else. Existence is proven; content is withheld. That boundary is the same one the app itself enforces, applied verbatim to the public surface.

Where it came from
From the published contract
curl "https://api.rivet.network/v1/documents/{documentId}/lineage?depth=2" \
  -H "Authorization: Bearer rk_live_…"

The export bundle

GET /v1/documents/{id}/export returns the document's ancestry as one self-describing JSON artifact: every included document's raw transition records — states, actors, timestamps, hashes, lineage links, metadata — plus the sealed hashes and the exact verification recipe. The body is canonical JSON: re-export the same unchanged document and you get the same bytes, and the Rivet-Bundle-Digest header is the sha256 of exactly those bytes.

Export the bundle (keep the digest)
From the published contract
curl -o bundle.json -D headers.txt \
  "https://api.rivet.network/v1/documents/{documentId}/export" \
  -H "Authorization: Bearer rk_live_…"
# headers.txt now holds Rivet-Bundle-Digest — keep it with the file
  • Ancestry only, by design. A bundle proves where a document came from as of issuance. What later came of it is a live query, never proof material — a new descendant changes nothing about an existing bundle, byte for byte.
  • Complete or absent. An ancestry too large to bundle answers conflict; a graph the service can't answer for is 503. A truncated proof would be worse than none, so one is never produced.
  • Exports have their own small per-key daily budget — rate_limited when spent.

Verify without trusting Rivet

This is the point of the bundle: hand the file to a lender, an auditor, a court — not API access — and they verify it with the script below (or any SHA-256 implementation and the bundle's own verification block). It recomputes every hash from the bundle's contents, checks the chain, and resolves every cross-document link. Change one byte of any covered field and it fails, loudly, naming the transition.

verify-bundle.mjs — dependency-light, copy-paste it whole
From the published contract
#!/usr/bin/env node
// ── verify-bundle.mjs — verify a Rivet provenance bundle OFFLINE ─────────────
//
// You do not have to trust Rivet to trust the bundle. This script recomputes
// every hash from the bundle's own contents and checks the chain, using
// nothing but Node's standard library. Anyone can read it end to end.
//
//   node verify-bundle.mjs <bundle.json> [--digest <sha256hex>]
//
// What it proves, in order:
//   1. INTEGRITY   — the file's sha256 matches --digest (the Rivet-Bundle-Digest
//                    header from the export, if you kept it).
//   2. HASHES      — every transition's hash recomputes from its own fields.
//                    Change one byte of any covered field and this fails, loudly.
//   3. CHAIN       — every transition links to real prior transitions of the
//                    same document, and depths are consistent (a DAG, no cycles).
//   4. LINEAGE     — every cross-document edge points at a hash that exists in
//                    the bundled parent document, or is declared sealed (a
//                    counterparty-side step: existence proven, content withheld).
//
// What the hash does NOT cover (contextual records, listed so nothing is
// oversold): note, event_type, producer, acknowledgement. The states, actors,
// timestamps, ancestry links and metadata ARE covered.
//
// Exit codes: 0 verified · 1 verification FAILED · 2 usage/read error.

import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";

const GENESIS = "genesis";
const NULL_STATE = "null";
const SYSTEM_ACTOR = "system";

const sha256 = (s) => createHash("sha256").update(s, "utf8").digest("hex");

/** Canonical JSON: object keys sorted recursively; arrays keep their order. */
function canonicalJson(value) {
    return JSON.stringify(sortKeys(value));
}
function sortKeys(value) {
    if (Array.isArray(value)) return value.map(sortKeys);
    if (value && typeof value === "object") {
        return Object.keys(value).sort().reduce((acc, k) => { acc[k] = sortKeys(value[k]); return acc; }, {});
    }
    return value;
}

/** The transition hash — the exact recipe the bundle's `verification` block states. */
function computeTransitionHash({ specVersion, documentId, t }) {
    const at = new Date(t.occurred_at);
    if (Number.isNaN(at.getTime())) throw new Error(`invalid occurred_at: ${t.occurred_at}`);
    const parents = [...(t.parent_transition_hashes ?? [])].sort().join(",") || GENESIS;
    const serialized = [
        `v=${specVersion}`,
        `boid=${documentId}`,
        `from=${t.from_state || NULL_STATE}`,
        `to=${t.to_state}`,
        `at=${at.toISOString()}`,
        `user=${t.triggered_by_user_id || SYSTEM_ACTOR}`,
        `org=${t.triggered_by_org_id}`,
        `parents=${parents}`,
        `depth=${t.graph_depth}`,
        `meta=${sha256(canonicalJson(t.metadata ?? {}))}`,
    ].join("|");
    return sha256(serialized);
}

// ── run ──────────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const file = args.find((a) => !a.startsWith("--"));
const digestIdx = args.indexOf("--digest");
const expectedDigest = digestIdx >= 0 ? args[digestIdx + 1] : null;
if (!file) {
    console.error("usage: node verify-bundle.mjs <bundle.json> [--digest <sha256hex>]");
    process.exit(2);
}

let raw;
try {
    raw = readFileSync(file);
} catch (err) {
    console.error(`could not read ${file}: ${err.message}`);
    process.exit(2);
}

let failed = 0;
const ok = (label, detail = "") => console.log(`  ✓ ${label}${detail ? ` — ${detail}` : ""}`);
const bad = (label, detail = "") => { failed += 1; console.error(`  ✗ ${label}${detail ? ` — ${detail}` : ""}`); };

// 1. INTEGRITY
const fileDigest = createHash("sha256").update(raw).digest("hex");
console.log(`bundle: ${file}`);
console.log(`sha256: ${fileDigest}`);
if (expectedDigest) {
    if (fileDigest === expectedDigest.toLowerCase()) ok("digest matches the export header");
    else bad("digest MISMATCH", `expected ${expectedDigest} — this is not the file Rivet exported`);
}

let bundle;
try {
    bundle = JSON.parse(raw.toString("utf8"));
} catch (err) {
    console.error(`  ✗ not valid JSON: ${err.message}`);
    process.exit(1);
}
if (bundle.bundle_format !== "rivet-provenance-bundle/1") {
    bad("unknown bundle_format", String(bundle.bundle_format));
}

const specVersion = bundle.spec_version;
const documents = Array.isArray(bundle.documents) ? bundle.documents : [];
const sealed = new Set((bundle.sealed ?? []).map((s) => s.transition_hash));
console.log(`root: ${bundle.root} · spec v${specVersion} · ${documents.length} document(s) · ${sealed.size} sealed step(s)\n`);

// 2 + 3. HASHES and CHAIN, per document
const hashesByDoc = new Map(); // documentId → Map(hash → depth)
for (const doc of documents) {
    const seen = new Map();
    hashesByDoc.set(doc.id, seen);
    let docOk = true;
    for (const [i, t] of (doc.transitions ?? []).entries()) {
        let recomputed;
        try {
            recomputed = computeTransitionHash({ specVersion, documentId: doc.id, t });
        } catch (err) {
            bad(`${doc.id} #${i}`, err.message);
            docOk = false;
            continue;
        }
        if (recomputed !== t.transition_hash) {
            bad(`${doc.id} #${i} hash MISMATCH`, `${t.from_state ?? "genesis"}→${t.to_state}: a covered field was altered`);
            docOk = false;
            continue;
        }
        // chain: every parent must already exist for this document; depth must be consistent
        const parentDepths = [];
        for (const p of t.parent_transition_hashes ?? []) {
            if (!seen.has(p)) { bad(`${doc.id} #${i} parent link`, `parent hash ${p.slice(0, 12)}… not among this document's prior transitions`); docOk = false; }
            else parentDepths.push(seen.get(p));
        }
        const expectedDepth = parentDepths.length ? Math.max(...parentDepths) + 1 : 1;
        if (t.graph_depth !== expectedDepth) {
            bad(`${doc.id} #${i} depth`, `graph_depth ${t.graph_depth}, expected ${expectedDepth}`);
            docOk = false;
        }
        seen.set(t.transition_hash, t.graph_depth);
    }
    if (docOk) ok(`${doc.document_type ?? "document"} ${doc.document_number ?? doc.id}`, `${doc.transitions?.length ?? 0} transition(s) verified`);
}

// 4. LINEAGE — cross-document edges resolve into the bundle or the sealed set
let crossChecked = 0, crossOk = true;
for (const doc of documents) {
    for (const t of doc.transitions ?? []) {
        for (const e of t.cross_edges ?? []) {
            crossChecked += 1;
            const target = hashesByDoc.get(e.parent_business_object_id);
            if (target?.has(e.parent_transition_hash)) continue;
            if (sealed.has(e.parent_transition_hash)) continue;
            bad(`cross-edge from ${doc.id}`, `${e.relationship_type} → hash ${String(e.parent_transition_hash).slice(0, 12)}… is neither in the bundle nor declared sealed`);
            crossOk = false;
        }
    }
}
if (crossChecked && crossOk) ok(`lineage`, `${crossChecked} cross-document link(s) resolve`);

console.log("");
if (failed === 0) {
    console.log("VERIFIED — every hash recomputes, the chain is intact, all lineage resolves.");
    process.exit(0);
}
console.error(`FAILED — ${failed} problem(s). This bundle does not prove what it claims.`);
process.exit(1);

Run it as node verify-bundle.mjs bundle.json --digest <the header>. It states plainly what the hash does not cover (the contextual note, event_type and producer fields) — nothing about the artifact is oversold.

Sharing verified history is the bundle. Delegated API access for third parties — consent grants a lender could hold — is a named future, deliberately not built yet: the file, its digest, and the script are the whole trust story today.