Second layer
The API, the format, the chain
Everything a receipt is made of, and every route that makes or serves one. Somebody handed you a receipt and you want to take it apart without our help — that is what this page is for, and taking it apart without our help is the whole design.
Access, honestly
There is no API key, no account and no plan that unlocks a higher rate. Every route below is either public, or gated by a token that belongs to one session or one receipt and is handed to whoever started it.
The routes that carry a document or the money refuse a request whose Origin header names a host that is not ours. A request that sends no Origin header at all — a server calling a server — is not refused, and we would rather say that here than have somebody discover it and assume it was hidden. It is not a supported integration surface: it has no versioning promise, no key to rotate and no rate limit tuned for it, and it can change in any deploy.
If you want to build on this properly, write and say so. A supported, versioned surface with keys is a thing we would build for a real caller and have not built for a hypothetical one.
Routes
Every answer is JSON. Session routes are no-store and noindex; a token that is well-formed but whose session has ended answers 404 with expired, which is a different thing from a token that never existed.
| Method | Route | What it does | Answers |
|---|---|---|---|
| POST | /api/check | Start a check. Multipart with a file, or JSON {text, ref}. A ref of word-win, word-mac or word-web over a posted string records that the caller assembled the string out of an open document, and the receipt then names the add-in's extractor rather than the file reader's. — who: same-origin | 202 {token, expires_at, arm} |
| GET | /api/check/:token | The session as it stands: every claim found, and each verdict as it lands. This is what the page polls while a check runs. — who: the token | the session view |
| GET | /api/check/:token/status | The completion signal, read off the database row rather than out of memory — so a deploy in the middle of a payment does not tell somebody who has paid that their session is gone. — who: the token | {receipt_id, sealed, checked_out, paid, closed, session} |
| GET | /api/check/:token/preview.json | The unsigned, unchained receipt-shaped preview of a check that has finished but has not been sealed. — who: the token | a receipt-shaped object with no signature |
| GET | /api/check/:token/binding.json | The binding bundle: what turns the per-claim fingerprints in a receipt back into the claims they were computed from. — who: the token | a downloaded file |
| POST | /api/check/:token/holds | Run the adversarial third level over one claim. Off by default, never sealed into a receipt, and it sends the sentence you wrote. — who: the token | 202 |
| DELETE | /api/check/:token | End the session now and drop the preview. The session expires on its own either way. — who: the token | {deleted: true} |
| POST | /api/checkout | Turn a finished check into an invoice. {token, email, tier}. — who: same-origin | a payment URL |
| GET | /api/receipt/:id | The public projection of a sealed receipt: every cited URL removed, every verdict, count and hash kept, signed in its own right. | the stored canonical bytes |
| GET | /api/receipt/:id/proof | The inclusion proof pointing at the day's Merkle root. 202 while that day has not been rooted yet. | an audit path |
| GET | /r/:id/receipt.json | The full sealed receipt, cited URLs and all. This is the creator's file, and the reason the projection exists. — who: the creator's link | the sealed bytes |
| GET | /r/:id/receipt.pdf | The printable receipt: the page somebody staples to a report and is asked about under oath. — who: the creator's link | a PDF |
| GET | /r/:id/qr.png | The receipt's QR as a raster, for a printed page. | a PNG |
| GET | /chain/head | Where the chain is now. | {seq, self_hash, issued_at, last_root} |
| GET | /chain/root/:date | One UTC day's signed root file, exactly as it was stored and published. | the stored canonical bytes |
| GET | /chain/verify | Our own replay of the chain, and the first break in it if there is one. Rooted days are re-derived on every call; the answer is memoised for sixty seconds. | {ok, checked, head_seq, first_broken, …} |
| GET | /keys.json | Every public key this install has ever signed with, so a verifier can pin them rather than trust the key a receipt carries about itself. | the key set |
| GET | /healthz | Whether the service is up. | {ok} |
A receipt route never confirms an id it does not hold, and an admin route without its token answers byte for byte like a path that has no route at all — same status, same headers, same body. That is deliberate: the difference between the two answers would otherwise be a map of what exists.
A server for AI agents
There is not one yet, and this is the only place we would announce it. The patch that reordered this product put a model-facing server in the same second layer as the routes above, and the honest position today is that the routes above are the whole machine surface: a check is one POST, its result is one GET, and a receipt is a public URL. When an agent-facing server is built it will wrap exactly those and nothing more, and this section will say so with the name and the transport in it. Until then there is nothing to install and nothing to configure.
The signature
A receipt is canonical JSON. Its self_hash is the SHA-256 of the canonical bytes of the receipt with two top-level members removed — self_hash itself and signatures — so the hash lives outside itself and the signatures live outside the hashed body. That is what lets a receipt be counter-signed later without its hash moving.
The signature is Ed25519 over the 32 raw bytes obtained by hex-decoding self_hash. Not the 64 hex characters, not the canonical JSON, not a hash of the hash: Ed25519 hashes its own input, so signing the digest directly is the whole scheme.
Every base64 value in the format is canonical — the standard alphabet, exact padding, no whitespace, and the unused low bits of the final symbol zero — so one key has exactly one spelling and two keys can be compared as bytes or as text with the same answer. A decoder that tolerates a missing pad or the URL-safe alphabet would read the same bytes from several spellings, and this format allows one.
A receipt carries exactly one signature whose role is issuer. Zero says it was never issued; two or more says it cannot state by whom; both are failures. The signature is verified against the key published for its key id, never against the key the receipt carries about itself — a receipt must not be allowed to vouch for itself.
The chain and the roots
There is one chain. seq is dense and starts at 1, and prev_hash at seq n is the self_hash of seq n−1. Both sit inside the signed body, so a receipt's position is signed with it: moving a receipt, or rewriting anything before it, changes its hash.
Every UTC day's chained receipts form one Merkle tree, hashed exactly as RFC 6962 defines it — leaves prefixed with a zero byte, interior nodes with a one, so a leaf can never be passed off as a node. The day's root is signed under a separate root key and published to a public repository we cannot quietly edit. An empty day still gets a file, so a gap in the series would be visible from outside.
A receipt's inclusion proof is an audit path into one of those roots. A receipt sealed today has no proof until tonight's root is cut, and the proof route says pending rather than inventing one.
What a public receipt does not carry
What a reader sees at a receipt's link is a projection: every cited URL removed, each locator reduced to {type, domain}, the case registry's own identifier for a record dropped along with it — the set of those identifiers would be the document's table of authorities. Everything else stays byte for byte, hashes and signatures included, so whoever holds the full receipt can bind the two together. A projection is signed in its own right, with one extra member the sealed receipt never has, so its whole visible surface — verdicts, counts, statuses, domains — is covered by a signature and not only by the receipt's hash.
The verifier
The verifier is MIT-licensed and mirrored on a public code host. It makes no request of its own: verification is hashing and signature checks over bytes you already hold, which is why the counter beside it on the verify page can honestly read zero.
It runs here, in your browser, as a compiled module served from this site. Load the verify page once, disconnect, and it still verifies — that is the check to run before you trust any of this, and it is the check to run again if we ever disappear.
From a terminal
The same checks run from a command line against a receipt on your disk. The package is not on the public registry yet, so npx exhibitb does not resolve today and we are not going to print it as though it did. What exists now is the browser build above, which is the same verifier, and the source on the public code host, which builds to both. When the package is published it will be named in the changelog on the day it happens, not before.
What the add-in sends
A receipt records which extractor produced the text it describes, and that field is worth nothing unless a stranger holding the same document can rebuild the same string and get the same hash. This is that specification. It is the published contract behind the exhibitb-word@1 label, and it is why a receipt made inside a document can be bound to the document at all.
exhibitb-word@1
exhibitb-word@1 — the string a receipt from the add-in describes
A receipt names the extractor that produced the text it was made from. When the check ran from inside Word, the text was assembled in the open document rather than parsed out of an uploaded file, and this is how. Rebuild the string from your own copy of the document, hash it, and it matches the text_sha256 in the receipt — or it does not, and you have learned something.
The order the string is built in
- Take the paragraphs of the document's main story, in the order Word lays them out. Paragraphs inside a table are part of that order: a table is read row by row, and each row cell by cell in reading order, at the point where the table sits.
- Then every footnote body, in footnote-number order.
- Then every endnote body, in endnote-number order.
- A paragraph's text is its own characters. A hyperlink contributes the words the reader sees; the address it points to is not inserted.
- The three groups are concatenated in that order — body, then footnotes, then endnotes — with nothing between them but the same separator that joins paragraphs.
What joins them
Every paragraph, footnote body and endnote body is joined to the next by two line feeds (U+000A twice), which is one blank line.
What is never in it
- the .docx file itself — no part of the container is uploaded
- headers and footers
- comments, and the identity of whoever wrote them
- tracked changes: deleted-but-unaccepted text is not sent, insertions are sent as the paragraph reads with them applied, and no revision author is sent
- styles, fonts, numbering, colour and every other piece of formatting
- images, charts, embedded objects and their captions' pictures (a caption's own words are a paragraph like any other)
- the file name, the folder it sits in, and every piece of document metadata
- fields, in favour of the result they display
The cleaning, applied last and to nothing else
- carriage returns become line feeds: and a lone both become
- control characters are removed (U+0000–U+0008, U+000B, U+000C, U+000E–U+001F, U+007F)
- soft hyphens (U+00AD) are removed
- spaces and tabs immediately before a line feed are removed
- three or more consecutive line feeds collapse to two
- leading and trailing whitespace is removed from the whole string
Checking your own rebuild
The cleaning is the same eight lines the uploaded-file path runs, and it is idempotent: running it twice changes nothing, so a rebuild that applies it once is comparable byte for byte. SHA-256 of the resulting UTF-8 bytes is the receipt's document.text_sha256; document.chars is that string's length in code units.
Why the two extractors count differently
The two paths do not read the same amount of a document, and the label is how a reader tells them apart. exhibitb-word@1 reads footnotes and endnotes. exhibitb-text@1 over an uploaded .docx reads the main story only, so a footnote-heavy report checked as a file will show fewer citations than the same report checked from inside Word. Neither number is wrong; they are counts of different strings, and the receipt says which one it made.
exhibitb-text@1
The other label. An uploaded .docx is parsed by a library that walks the document's own children, which is the main story and the tables inside it — footnotes and endnotes are held elsewhere in the container and are not read. Everything else about the string is the same, including the cleaning below and the hash over it.
The format in full
The canonical form, the receipt schema member by member, the chain rules, the Merkle construction, the root file, the inclusion proof and the projection rule are specified in one document, with test vectors beside it — the same file, byte for byte, that ships in the verifier's own checkout. Every number in it is one a third party can reproduce.
Test vectors
- the canonical form of an object, with the worked examples that pin the whitespace and the number formatting
- self-hash vectors, including an object carrying a wrong self-hash and a nonsense signature array hashing to what it hashes to without them
- a signed receipt, its key set and its verification result
- Merkle roots for trees of 0, 1, 2, 3 and 7 leaves, with every audit path
If something on this page is wrong, or a check you wrote against it fails and you believe it should not, the support address in the footer reaches a person who will look at the bytes with you.