Docs
From nothing to a verified record.
A key, three packages, and one method that matters: verify what the server tells you on your own machine. Everything below is generated from files the test suite checks, so the page cannot promise what the code does not do.
1. Get a key
Self-serve, no verification mail, no card: create a sandbox with an email and you get an organization and its admin key, shown once. From Node, the same thing:
import { PalynthClient } from "@palynth/client";
const { client, key } = await PalynthClient.signup({ email: "you@company.com" });2. Install
| Package | What it is | Install |
|---|---|---|
@palynth/client | the hosted API from Node, with local verification | npm i @palynth/client |
@palynth/provenance | the kernel: canonical JSON, hash chains, Merkle roots, signatures | npm i @palynth/provenance |
palynth | the CLI: compile PDFs to capsules, inspect, verify, recover | npx palynth --help |
palynth-provenance | the same kernel in Python, byte-identical, self-testing | pip install "palynth-provenance[signing]" |
All three kernels are pinned to one golden fixture that ships inside every package; the Python one proves it with python -m palynth_provenance.selftest.
3. Quickstart
import { readFileSync } from "node:fs";
import { PalynthClient } from "@palynth/client";
const c = new PalynthClient({ apiKey: process.env.PALYNTH_API_KEY }); // or: (await PalynthClient.signup({ email })).client
const { projectUuid } = await c.projects.create("Site A");
const p = c.project(projectUuid);
// A document becomes a capsule: the PDF with proof of every claim inside it.
const { documentSha256 } = await p.capsules.upload(readFileSync("door-schedule.pdf"));
// Events that name the capsule and a page are pinned to the operators
// that state their claims. Every event is signed with the project's key.
await p.append([
{ assetUuid: "door-101", eventType: "ASSET_CREATED", reason: "schedule row 1",
sourceDocUuid: documentSha256, page: 1, payload: { mark: "101", hardwareSet: "HS-3" } },
]);
// Do not take the server's word for it: pull the chain and verify it here.
const v = await p.verifyLocal();
console.log(v.ok, v.root, v.signatures); // true, <merkle root>, { ok: true, signed: 1, bad: [], keyId: "ed25519:…" }
// Attest the root in the Sigstore transparency log, then check the anchor.
const anchor = await p.anchor();
console.log(await c.anchorVerify(anchor.id, { online: true }));
Every line above runs in this package's test against a real server.
Your key, not ours
Above, the service signs. That is convenient and it means the authority behind every event is the service's — a reader who trusts those events is trusting us. If that is the wrong answer for your records, pin your own key as the project's trust root and sign locally. We can still store your chain; we cannot write in your name, and you can prove it.
import { keyFromSeed } from "@palynth/provenance";
const me = keyFromSeed(process.env.PALYNTH_SEED); // 32 bytes of hex, yours alone
const { projectUuid } = await c.projects.create({ name: "Site A", rootKeyId: me.keyId });
const p = c.project(projectUuid);
// Admit the key that will do the writing. Signed by you, recorded in the chain.
const pipeline = keyFromSeed(process.env.PIPELINE_SEED);
await p.writer(me).admit({ keyId: pipeline.keyId, role: "writer", label: "site pipeline" });
// The pipeline writes. Each call fetches the head, builds, signs, and posts.
await p.writer(pipeline).append([
{ assetUuid: "door-101", eventType: "ASSET_CREATED", reason: "schedule row 1" },
]);
// Judge the result against your root, on your machine.
const v = await p.verifyTrustLocal({ trustRootKeyId: me.keyId });
console.log(v.ok, v.trust.summary);
// true "every one of 2 events was written by a key this project admitted (2 keys in the registry)"
The registry is not a table beside the chain: KEY_REGISTERED and
KEY_REVOKED are events inside it, so changing who was allowed to write
breaks the same hashes that changing a door would. A key cannot admit
itself. Retiring a key leaves everything it already signed trusted; if a key
was stolen, revoke(keyId, { reason, retroactive: true }) withdraws
everything it ever signed.
An event we sign on such a project comes back unknown-key from your own
check — which is the point, and is asserted in this package's tests.
If someone else appends between your fetch and your post, the service
refuses with 409 and LocalWriter rebuilds once against the new head,
then throws. It does not retry in a loop: your signed events would be
silently re-ordered behind writes you never saw.
4. Errors
Every failure throws PalynthError with status, the server's message,
the parsed body, and the rateLimit headers. A 429 carries
retryAfter in seconds. The client never retries by itself: an append is a
write to an append-only chain.
5. Verify without trusting anyone
verifyLocal() pulls the chain and recomputes every hash, every link, and every signature under the project's key on your machine. The same check runs in your browser on the verification page, and in Python with palynth_provenance.verify. An anchor ties a root to Sigstore's public Rekor log; anchorVerify checks the inclusion proof offline.
API reference
Base URL: https://palynth-api-production.up.railway.app. JSON in and out,
UTF-8. Errors are { "error": "<message>" } with the HTTP status.
@palynth/client wraps every route below; this page is what it wraps.
Authentication
| Header | Used by |
|---|---|
Authorization: Bearer pk_<48 hex> |
everything under /v1 except /v1/admin/* |
X-Admin-Token: <token> |
/v1/admin/* only (platform admin) |
Keys belong to an organization and carry a role: reader (GET),
writer (GET + POST), admin (also manages the org's keys). A key sees only
its own org's projects; anyone else's project is a 404, never a 403, so
existence is not disclosed.
Rate limits
Every response carries x-ratelimit-limit and x-ratelimit-remaining for
the bucket it drew from. Per key, per minute: 600 requests, 10 capsule
compiles, 6 anchors. Unauthenticated traffic: 60 per address. Over the
limit is 429 with Retry-After in seconds.
Routes
Health
GET /healthz → { "ok": true }. No auth.
Self-serve signup
POST /v1/signup { "email", "name"? } → 201 { orgId, orgName, key, keyId, role: "admin", limits, note }. No auth.
An email is enough for a sandbox: a new organization under that contact
(named after name, or the email's domain), and an admin key for it, shown
once. The email is recorded, not verified. One signup per address per
minute (429 with Retry-After otherwise). The site's
/access page calls this from the browser.
Platform admin
POST /v1/admin/orgs { "name" } → 201 { orgId, name, createdAt }
POST /v1/admin/keys { "orgId"?, "label", "role"? } → 201 { id, key, orgId, role, note }.
Without orgId a new org is created and named after the label. key is
shown once; only its hash is stored.
GET /v1/admin/keys (X-Admin-Token) → { keys: [{ id, label, role, orgId, createdAt, revokedAt }] } — every key on the platform.
DELETE /v1/admin/keys/:id (X-Admin-Token) → 200 { id, label, orgId, role, revokedAt }. Withdraws a key in any org.
Minting without withdrawing is a hole rather than a simplification: an
operator who cannot take a leaked key back has no answer when one leaks.
This is deliberately platform-wide, and deliberately a separate path from
DELETE /v1/org/keys/:id, which stays unable to reach across a tenant
boundary whatever it is passed. Revocation is permanent; mint a replacement
rather than expecting a key back. Revoking an already-revoked key is a
no-op and returns the original time.
Organization
GET /v1/org → { orgId, name, role, keyId, limits: { perMinute, compilePerMinute, anchorPerMinute } }
GET /v1/org/keys (admin) → { keys: [{ id, label, role, createdAt, revokedAt }] }
POST /v1/org/keys (admin) { "label", "role"? } → 201 { id, key, role, note }
DELETE /v1/org/keys/:id (admin) → { revoked: id }. A key cannot revoke itself (409).
Projects
GET /v1/projects → { projects: [{ projectUuid, name, eventKeyId, createdAt }] }
POST /v1/projects (writer) { "name", "rootKeyId"? } → 201 { projectUuid, name, eventKeyId, trustRootKeyId, hostHoldsRoot, createdAt }.
Each project gets its own Ed25519 event-signing key; eventKeyId is
ed25519:<public key hex>. The seed is sealed at rest and never returned.
rootKeyId pins a trust root this service does not hold — your key. The
project's authority then rests on you, not on us: we can still store events,
but every event a reader trusts is one your key signed. Left out, the
project's own key is the root (hostHoldsRoot: true), which is what you want
when you would rather we did the signing.
Events
POST /v1/projects/:id/events (writer) [draft, …] → 201 { events, report }
A draft:
{
"assetUuid": "door-101",
"eventType": "ASSET_CREATED",
"reason": "schedule row 1",
"payload": { "mark": "101" },
"sourceDocUuid": "<document sha-256, a capsule of this project>",
"page": 1,
"detectedBy": "my-pipeline",
"confidence": 1,
"causedByEventId": null,
"affectedAssetUuids": [],
"actor": { "detectedBy": "my-pipeline", "responsibleRole": "estimator" },
"timestamp": "2026-09-05T10:00:00.000Z"
}
Only assetUuid, eventType, reason are required. At most 5000 drafts per
call. The service assigns id, seq, prevHash, computes hash, signs it
(signature), and — when sourceDocUuid names a capsule of this project and
page is given — attaches palyEvidence pinning the event to the operators
that state its claims, before hashing. report[i] is
{ assetUuid, pinned, unresolved: [{ claim, reason }] }; reasons include
event-has-no-page, no-capsule-named, no-capsule-for-document.
GET /v1/projects/:id/chain → { events: [ProvenanceEvent, …] }, in seq order, exactly as written.
GET /v1/projects/:id/head → { projectUuid, seq, head, trustRootKeyId } — what
to build the next event on when you sign your own.
DELETE /v1/projects/:id (admin) { "confirm": "<the project's exact name>" } → 200 { deleted, name, events, capsules, capsuleFilesRemoved, anchorsForgotten, note }
Erases the project, its chain, its capsule rows and their files. The exact
name is required in the body: an id in a URL is easy to get wrong and
impossible to take back. 404 for a project outside your organisation, which
discloses nothing about whether it exists.
This does not bend the append-only rule. It removes a whole project; it cannot remove or edit an event inside one. Those are different promises, and both are kept: "nothing in this history was quietly changed" is the chain's, and "you may take your records out of our hands entirely" is one a service holding someone else's records also has to keep.
Anchors already published cannot be retracted, by this service or by
anyone. They live in a transparency log outside our control, which is the
entire point of anchoring, and they carry hashes, an event count and key ids
— never document content. The response says how many survive. Export first
with /export: deletion keeps no copy.
GET /v1/projects/:id/export → an uncompressed POSIX tar
(application/x-tar): manifest.json, VERIFY.md, chain.json,
anchors.json, and capsules/<sha>.paly for every document the events
cite. tar -tvf lists it; palynth verify-bundle <file> --root ed25519:…
checks all of it with nothing of ours involved.
The manifest's digests are a packing list — they catch a truncated download,
not a lying exporter, since anyone who could alter a member could alter the
digest beside it. What the verifier actually rests on is inside: every event
hashes and links, every signature verifies under a key the registry
admitted, every capsule embeds the document it is filed under, and an anchor
commits the root to a log we do not control. The manifest also records the
trust root the exporter used; pass your own with --root and yours is used,
because a root handed to you by the party being checked is not a root.
A capsule missing from the volume is named in an x-palynth-capsules-missing
response header rather than silently dropped from an archive that claims to
be complete.
Events you signed yourself
POST /v1/projects/:id/events/signed (writer) [ProvenanceEvent, …] → 201 { events, accepted, head, nextSeq, trust }
For a key we do not hold. Build each event with the kernel against /head,
sign it, and post it whole. In @palynth/client this is
project(id).writer(key).append(drafts), which does the fetch, the build,
the signing and the post — and rebuilds once on a 409 rather than looping. Nothing is rewritten — the bytes you send are the
bytes stored, because any edit would break your signature — so every check is
a refusal:
| Refusal | Meaning |
|---|---|
409 |
seq/prevHash do not continue the chain as it stands now. Re-fetch /head and rebuild; we will not re-link a signed event. |
400 |
the body and its hash disagree, the event is unsigned, or the signature does not verify. |
403 |
the signing key is not trusted at that position. Admit it with a KEY_REGISTERED first. |
Evidence is not pinned on this path, and cannot be: pinning edits the event, and an edited event is an unsigned one. Resolve first, then sign.
Pinning a claim to the bytes that state it
POST /v1/projects/:id/pin (writer) {assetUuid, eventType, reason, payload, sourceDocUuid, page} → 200 { palyEvidence, unresolved }
Resolves the draft's payload values against the compiled capsule and returns
the evidence block. It signs nothing, writes nothing, and returns the same
answer twice. Put the block in the event you build, then sign that event and
post it to /events/signed. This is how a project that pins its own trust
root gets both properties at once: the authority is yours and the claims are
still tied to the operators that state them.
sourceDocUuid and page are required; without a page there is nothing to
resolve against. Only payload values are resolved. A reason is prose, and
a draft with no claims comes back with palyEvidence: null and
unresolved: [{claim: "*", reason: "no-claims-in-payload…"}] rather than
silently unpinned.
A value that appears in more than one place on the page comes back
unresolved with reason ambiguous-on-page rather than pinned to whichever
occurrence sorted first. Finish codes and hardware sets repeat constantly, so
this is common and it is not a failure: name the record in the payload — the
room number, the door mark — and the value resolves to the one written on
that line.
You do not have to take the block on trust, and should not. It names operator
and entity ids inside a capsule you can download from
/v1/projects/:id/capsules/:sha; checking that those ids carry the text and
the byte ranges claimed is exactly what any verifier does with the finished
record.
Resolving locally instead is the stricter option and needs the capsule and
the bridge; @palynth/paly-bridge is not published today, which is why this
route exists.
Your document back, byte for byte
GET /v1/projects/:id/capsules/:sha/pdf (reader) → 200 application/pdf
The original PDF, recovered from the capsule. The bytes are checked against
the capsule's manifest on the way out, so what you get is what was uploaded
or an error — never a substitute. The response carries x-document-sha256
so you can hash what arrived and compare. Until this route, getting a
document back meant downloading the .paly and opening it as SQLite.
What changed between two issues
POST /v1/projects/:id/diff (reader) {from, to, fromLabel?, toLabel?} → 200 RevisionComparison
from and to are document sha256s of two capsules in this project. The
route signs nothing and writes nothing, and the same call twice returns the
same answer.
Identity is resolved before differencing, so a room renumbered from 101 to
101A comes back as a rename and not as a deletion plus an insertion. Each
schedule is compared only against the schedule with the same headers in the
other issue: a door schedule and a room schedule both contain a "101", and
differencing across them would report a door's width as a change to a room. A
schedule that appears in only one issue lands in onlyInFrom / onlyInTo.
Every change names the entities and operators that state it, on both sides,
and a changed value points at the text run that states it rather than at the
row containing it. Those ids are in a capsule you can download from
/capsules/:sha, so the report can be checked rather than believed.
Check nothingComparable before believing an empty result. No changes and
nothing readable are different answers, and this field is what separates
them. A schedule that could not be read as a population of identified rows —
a first column that repeats, a row with no value in it — appears in
unusable with a named reason rather than being silently skipped.
The key is the first column, which is a fact about how tables are drawn rather than a vocabulary, so this works outside construction.
Comparing a document with itself is a 400: there is no revision between a document and itself.
Pass drafts: true and the response also carries drafts: one appendable
event per change — ASSET_RENAMED, PROPERTY_CHANGED, ASSET_CREATED,
ASSET_REMOVED — each with the evidence the comparison resolved. The route
still writes nothing. Post them to /events and the host signs and re-pins
them; sign them yourself and post to /events/signed and the evidence is
kept verbatim. Either way the revision is then in the chain, which is the
difference between a report and a record.
A draft's prior side lives under payload.previous, which names the
document it was read from. The resolver treats values there as claims about
that document (stated-in-another-document) and does not look for them in
this one, because an old value that happens to appear elsewhere on the new
page would otherwise be pinned to an unrelated place. A removal cites the
last document that stated the asset; an absence cannot be pointed at.
Trust: which keys this project accepts
GET /v1/projects/:id/trust → { ok, root, hostHoldsRoot, keys, counts, refusals, untrusted, untrustedTotal, summary, checkedAt }
The key registry, replayed from the pinned root. The registry is not a table
beside the chain — it is KEY_REGISTERED and KEY_REVOKED events inside
the chain, so changing a key's history breaks the same hashes that changing a
door would. One key is pinned from outside (the root); every other key was
admitted by an event a trusted admin signed. A key cannot admit itself.
Per-event verdicts are trusted, unsigned, invalid-signature,
unknown-key, revoked-key. untrusted lists every event that is not
trusted, capped at 500.
POST /v1/projects/:id/keys (admin) { keyId, role, label, notBefore?, notAfter? } → 201 { event, keys }
Admits a key: appends a KEY_REGISTERED signed by the root. role is
writer or admin; only admin may change the registry. label is
required — a key nobody can name is a key nobody can revoke. notBefore and
notAfter are advisory: validity is judged by chain position, which
cannot be forged, not by a timestamp the writer chose. Returns 409 when the
project pinned a root we do not hold; sign the KEY_REGISTERED yourself and
post it to /events/signed.
POST /v1/projects/:id/keys/:keyId/revoke (admin) { reason, retroactive? } → 201 { event, keys }
reason is required. By default the key stops being trusted from that point
forward and everything it signed before stays trusted — an ordinary
retirement. retroactive: true treats the key as compromised: everything it
ever signed becomes untrusted, including events already anchored.
GET /v1/projects/:id/root → { root, eventCount, eventKeyId } — Merkle root over the event hashes.
GET /v1/projects/:id/verify → { ok, chainOk, failures, eventCount, signed, signaturesOk, badSignatures, trust, root }.
The server's verdict on both halves: chainOk is the hashes and links,
trust is whether every event was written by a key this project admitted.
ok is both. Verify locally too: pull chain and run the kernel
(@palynth/provenance verifyChain + verifyEventSignature,
@palynth/trust verifyTrustedChain, or @palynth/client verifyLocal /
verifyTrustLocal).
Capsules
POST /v1/projects/:id/capsules (writer; body application/pdf, up to 64 MB) → 201 { documentSha256, pageCount, entityCount, coverage, textOpsRanged }.
Compiles the PDF into a .paly capsule kept beside the chain. Bucket: 10 per minute.
GET /v1/projects/:id/capsules → { capsules: [{ document_sha256, project_uuid, page_count, entity_count, created_at }] }
GET /v1/projects/:id/capsules/:sha → the .paly bytes (application/octet-stream). 410 if the file is gone from the volume.
Anchors
POST /v1/projects/:id/anchor (writer) → 201 { id, statement, logIndex, integratedTime, uuid }.
Signs statement = { v: 1, projectUuid, root, eventCount, eventKeyId, anchoredAt, trustRootKeyId, registryDigest, trustedEvents }
with the service's ECDSA P-256 anchor key and records it in Sigstore's Rekor
as a hashedrekord. 409 on an empty chain. Bucket: 6 per minute.
The service also anchors on a schedule: every 24 hours (and once shortly after boot) it sweeps every project and anchors those whose chain has grown since their last anchor. Unchanged chains are not re-anchored; the log is shared, and an anchor that says nothing new is noise in it.
GET /v1/projects/:id/anchors → { anchors: [{ id, root, eventCount, logIndex, integratedTime, createdAt }] }
An anchor's statement carries trustRootKeyId, registryDigest and
trustedEvents alongside the root (D-058). The root already commits to the
registry, since key events are events; these say which root the registry was
replayed from, and what it replayed to — so a later replay that disagrees
reveals a policy that changed under a record that did not. Anchors made
before D-058 do not carry them and verify unchanged.
GET /v1/anchors/:id/verify[?online=1] → { offline: { ok, problems }, online: { ok, problems } | null, anchor }.
Offline recomputes the anchored prefix of the chain (never trusting a stored
hash), then checks the statement hash, the signature, the Rekor body's
commitment to both, and the RFC 9162 inclusion proof. online=1 also asks
the log whether it still holds the same entry.
Backup (platform admin)
GET /v1/admin/backup/manifest → counts, capsule digests, seal key id.
GET /v1/admin/backup/db → one consistent SQLite snapshot.
GET /v1/admin/backup/capsules/:project/:sha → the .paly bytes.
See docs/RUNBOOK-api.md; pnpm api:backup pulls and verifies all of it.
The event
{
"id": "evt-<project8>-0000",
"seq": 0,
"projectUuid": "…",
"assetUuid": "door-101",
"eventType": "ASSET_CREATED",
"payload": { "mark": "101" },
"sourceDocUuid": "…",
"page": 1,
"reason": "schedule row 1",
"detectedBy": "api",
"confidence": 1,
"causedByEventId": null,
"affectedAssetUuids": [],
"actor": { "detectedBy": "api" },
"timestamp": "…",
"palyEvidence": { "…": "present only when pinned; part of the hashed form" },
"prevHash": "<64 hex>",
"hash": "<64 hex>",
"signature": { "alg": "ed25519", "keyId": "ed25519:<64 hex>", "sig": "<128 hex>" }
}
hash is SHA-256 over the canonical JSON of the hashed fields (id and
signature are derived, never hashed). signature is Ed25519 over the UTF-8
bytes of hash. The canonical form is frozen and identical across the
TypeScript kernel, the browser port, and the Python kernel; the golden
fixture that pins all three ships inside every package.