Sealing a trace
A trace groups events, anchored documents and AI attestations into one bundle. This page covers its statuses, how items join, the composite hash and the proof.
A trace groups the events, anchored documents and AI attestations of one process, such as an invoice run. You create it, attach items by passing its trace_id, and seal it when the process is done.
Sealing runs in the background. A worker orders every item hash by time, hashes the concatenation with SHA-256, and writes a trace.sealed event signed with the tenant key.
A sealed trace takes no more items and cannot be deleted. The proof endpoints then return every item with its hash, signature and public key, plus the seal event.
| Create | POST /v1/traces with a label; the trace starts open |
|---|---|
| Add items | trace_id on POST /v1/events, POST /v1/document/anchor and POST /v1/ai/attestations |
| Seal | POST /v1/traces/{trace_id}/seal answers 202; a worker finishes the seal |
| Statuses | open, sealing, sealed |
| Composite hash | SHA-256 over the raw item hashes in timestamp order |
| Proof | GET /v1/traces/{trace_id}/proof, the public page, or the PDF |
Create · open
POST /v1/traces stores the trimmed label and optional metadata with status open. One open trace per label per tenant.
Add items · open
Events, documents and attestations carry trace_id in their own create call. The trace must still be open.
Seal · sealing
POST /v1/traces/{trace_id}/seal moves open to sealing in one conditional update and queues the seal job. The call answers 202.
Done · sealed
The worker hashes the items, writes a signed trace.sealed event and sets status sealed in one transaction. Poll GET /v1/traces/{trace_id}.
| open to sealing | POST /v1/traces/{trace_id}/seal, only while the status is open. A second call answers 409 trace_not_open. |
|---|---|
| sealing to sealed | The worker, with the trace row locked, in the same transaction that writes the seal event. |
| sealing to open | The seal call finds no event (400 trace_empty), cannot queue the job (500 seal_publish_failed), or the worker gives up. |
| stays sealing | 500 encode_failed, when the seal job cannot be serialized. The status is not put back. |
| sealed | Final. No item can join, the trace cannot be deleted, and the proof endpoints answer. |
When a seal fails
- The worker tries up to 6 times. The wait doubles from 500 ms, caps at 30 s, and carries jitter.
- A trace that is missing or no longer sealing is not retried. Every other error is.
- After the last attempt the status goes back to open and you can seal again.
- Nothing is written on failure. The seal event and the composite hash are committed together or not at all.
Deleting a trace
- DELETE /v1/traces/{trace_id} removes an open trace that has no events.
- A sealed or sealing trace answers 409 trace_not_open.
- An open trace with at least one event answers 409 trace_has_events. Seal it instead.
- The check counts events only. Documents and attestations on the trace do not block a delete.
There is no attach endpoint. An item joins a trace by carrying trace_id in the call that creates it. Each of the three create calls runs the same check.
- The trace is looked up by id inside the calling key's tenant. A trace owned by another tenant is not found.
- The status must be open. Sealed and sealing are refused.
- The check runs before the item is queued. A refused item is not stored.
- A stored item keeps the trace_id and appears in GET /v1/traces/{trace_id} under events, documents or attestations.
POST /v1/events | Id of an open trace owned by the same tenant; the event is attached to that trace and the trace must not be sealed or sealing. Endpoint |
|---|---|
POST /v1/document/anchor | Id of an open trace owned by the tenant; the document is attached to it and the trace must not be sealed or sealing. Endpoint |
POST /v1/ai/attestations | An open trace owned by the tenant; the attestation is attached to it and included when the trace is sealed. Endpoint |
| Trace not in your tenant | 404 trace_not_found from all three endpoints. |
|---|---|
| Trace is sealed | 409 trace_sealed from events; 409 trace_not_open from documents and attestations. |
| Trace is sealing | 409 trace_sealing from events; 409 trace_not_open from documents and attestations. |
| Any other status | 409 trace_invalid_status from events; 400 trace_invalid_status from documents and attestations. |
One SHA-256 over every item hash in the trace, in the order the items were recorded. Each kind of item contributes the hash it already has.
| event | payload_hash, the SHA-256 of the canonical payload. Ordered by ingested_at. |
|---|---|
| document | document_hash, the 64 hex characters the caller sent when anchoring. Ordered by anchored_at. |
| attestation | payload_hash, the SHA-256 of the canonical request bytes. Ordered by created_at. |
- Lock the trace row and confirm the status is still sealing.
- Read every item with this trace_id from the three tables into one list, ordered by timestamp across all kinds.
- Concatenate the raw 32-byte hashes in that order. Hex text is not used.
- SHA-256 the concatenation. The 32-byte result is composite_hash, returned as 64 hex characters.
- The seal event, the label and the metadata are not inputs.
5be1…09afc27d…41e0e90a…7b1318f4…d6c2d41c7e0a…b92f5a36Sealed · signed · closed to writesimport { createHash } from "node:crypto";
const traceId = "7c1e4b52-9a0f-4d2e-b6f3-2f8a61c0d9e4";
const res = await fetch("https://api.invoance.com/v1/traces/" + traceId + "/proof", {
headers: { Authorization: "Bearer " + process.env.INVOANCE_API_KEY },
});
if (!res.ok) throw new Error(res.status + " " + (await res.text()));
const bundle = await res.json();
// One list across all three kinds, in timestamp order.
const key = (t) => t.replace(/(\.\d+)?Z$/, (_, frac) => (frac ?? ".").padEnd(10, "0"));
const items = [
...bundle.events.map((e) => [e.timestamp, e.content_hash]),
...bundle.documents.map((d) => [d.timestamp, d.document_hash]),
...bundle.attestations.map((a) => [a.timestamp, a.payload_hash]),
].sort(([a], [b]) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
// SHA-256 over the raw 32-byte hashes, concatenated in that order.
const hash = createHash("sha256");
for (const [, hex] of items) hash.update(Buffer.from(hex, "hex"));
const recomputed = hash.digest("hex");
console.log(recomputed === bundle.composite_hash ? "composite hash matches" : "mismatch");
The worker writes one more event to the ledger, of type trace.sealed. It carries the composite hash and is signed like any event.
event_type | trace.sealed |
|---|---|
| payload | {trace_id, composite_hash, event_count, sealed_at} as compact JSON in that key order. sealed_at carries a +00:00 offset. |
| payload as read | Postgres stores the payload as JSONB and returns the keys reordered. Hash the declared order. |
payload_hash | SHA-256 of the compact payload. request_hash is the same value. |
signed_payload | {v: 1, event_id, trace_id, tenant_id, event_type, composite_hash, event_count, sealed_at}, compact JSON in that order. Here sealed_at ends in Z. |
| signature | Plain Ed25519 over signed_payload with the tenant key. The same key and method sign every event. |
| stored as | A ledger event with this trace_id, retention indefinite, source api or dashboard, ingested_at equal to sealed_at. |
In the same transaction the trace gets status sealed, sealed_at, seal_event_id, composite_hash, event_count and item_counts. event_count here counts every hashed item, not only events.
{
"trace_id": "7c1e4b52-9a0f-4d2e-b6f3-2f8a61c0d9e4",
"composite_hash": "e01187fee8acb0969976df6f4cd49164e8443195165355990daa878cf7e12b41",
"event_count": 4,
"sealed_at": "2026-09-22T09:01:54.620015+00:00"
}
GET /v1/traces/{trace_id}/proof
- Needs an API key with the read scope and a sealed trace. Open or sealing answers 409 trace_not_sealed.
- events lists every event except the seal, in ingested_at order, with payload, content_hash, signature and public_key.
- documents carry document_hash and attestations carry payload_hash, each with signature and public_key. Attestations add payload_raw, the exact bytes behind payload_hash, when object storage returns them.
- seal_event holds the trace.sealed event: content_hash, signature and public_key.
- event_count is the length of events. item_count adds documents and attestations.
- verification.composite_hash_valid and all_signatures_valid are written as true on every bundle. They are not recomputed per request.
/proof/trace/{trace_id}
- /proof/trace/{trace_id} reads GET /v1/proof/trace/{trace_id}, which needs no API key and answers only for sealed traces the owner made public.
- The page recomputes the composite hash in the browser, SHA-256 over the raw item hashes in time order, and compares it with the recorded value.
- It also checks that the listed items match item_counts recorded at sealing, and that a seal event with a signature is on record.
- It does not verify the seal signature. The signed record includes tenant_id, which the public proof does not return.
- A visitor can paste a composite hash or an item hash and the page compares it locally. Nothing pasted is sent anywhere.
GET /v1/traces/{trace_id}/proof/pdf
- GET /v1/traces/{trace_id}/proof/pdf returns the bundle as a PDF attachment named trace-{trace_id}-proof.pdf.
- Same rules as the JSON bundle: API key with the read scope, sealed traces only, 409 trace_not_sealed otherwise.
- The renderer is the one the dashboard export uses, so the two files match.
{
"version": "1.0",
"trace_id": "7c1e4b52-9a0f-4d2e-b6f3-2f8a61c0d9e4",
"label": "Invoice batch 2026-09",
"tenant_domain": "acme.example",
"status": "sealed",
"source": "api",
"created_at": "2026-09-22T08:14:07Z",
"sealed_at": "2026-09-22T09:01:54Z",
"composite_hash": "e01187fee8acb0969976df6f4cd49164e8443195165355990daa878cf7e12b41",
"event_count": 2,
"item_count": 4,
"events": [
{
"event_id": "3f9a2c71-6b0d-4e85-9c12-7d4e8a1b5f60",
"event_type": "invoice.received",
"payload": {
"invoice_number": "INV-2026-0441",
"amount_cents": 128500,
"currency": "EUR"
},
"content_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"timestamp": "2026-09-22T08:15:31Z",
"signature": "b7c1d4e9f2a5068397c0d3e6f1a4b58d2e4f6a8c0b1d3f5e7a9c1b3d5f7e9a0c4d6e8f0a2b4c6d8e1f3a5b7c9d0e2f4a6b8c0d2e4f6a8b1c3d5e7f9a0b2c4d6e",
"public_key": "1f8e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0"
},
{
"event_id": "9b2d4e6f-1a3c-4d5e-8f70-1c2b3a4d5e6f",
"event_type": "invoice.approved",
"payload": {
"invoice_number": "INV-2026-0441",
"approved_by": "finance_lead"
},
"content_hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
"timestamp": "2026-09-22T08:22:45Z",
"signature": "3e5f7a9c1b2d4f6e8a0c2e4f6a8b0d1f5c7e9a1b3d5f7a9c0e2f4a6b8d0f2a4c7e9b1d3f5a7c9e0b2d4f6a8c1e3f5a7b9d0f2a4c6e8b1d3f5a7c9e0b2d4f6a8c",
"public_key": "1f8e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0"
}
],
"documents": [
{
"document_id": "5e8f1a2b-3c4d-4e5f-8a6b-7c8d9e0f1a2b",
"document_ref": "INV-2026-0441.pdf",
"document_hash": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
"event_type": "document.anchored",
"signature": "9a0b2c4d6e8f1a3b5c7d9e0f2a4b6c8d1e3f5a7b9c0d2e4f6a8b1c3d5e7f9a0b2c4d6e8f1a3b5c7d9e0f2a4b6c8d1e3f5a7b9c0d2e4f6a8b1c3d5e7f9a0b2c4d",
"public_key": "1f8e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0",
"timestamp": "2026-09-22T08:19:02Z"
}
],
"attestations": [
{
"attestation_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"attestation_type": "output",
"model_name": "gpt-4.1",
"model_provider": "openai",
"payload": {
"type": "output",
"payload": {
"input": "Summarize invoice INV-2026-0441",
"output": "Invoice for 1,285.00 EUR, due 2026-10-22."
},
"context": {
"model_provider": "openai",
"model_name": "gpt-4.1",
"model_version": "2026-04-14"
},
"subject": null,
"trace_id": "7c1e4b52-9a0f-4d2e-b6f3-2f8a61c0d9e4"
},
"payload_raw": "{\"type\":\"output\",\"payload\":{\"input\":\"Summarize invoice INV-2026-0441\",\"output\":\"Invoice for 1,285.00 EUR, due 2026-10-22.\"},\"context\":{\"model_provider\":\"openai\",\"model_name\":\"gpt-4.1\",\"model_version\":\"2026-04-14\"},\"subject\":null,\"trace_id\":\"7c1e4b52-9a0f-4d2e-b6f3-2f8a61c0d9e4\"}",
"payload_hash": "bef6d7205ca5b97289bdb4cdb60a6de5b1fd8a2e1e5a9b663f1cdea305b644ab",
"signature": "5c7e9a1b3d5f7a9c0e2f4a6b8d0f2a4c7e9b1d3f5a7c9e0b2d4f6a8c1e3f5a7b9d0f2a4c6e8b1d3f5a7c9e0b2d4f6a8c3e5f7a9c1b2d4f6e8a0c2e4f6a8b0d1f",
"public_key": "1f8e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0",
"timestamp": "2026-09-22T08:26:10Z"
}
],
"seal_event": {
"event_id": "c4d7e8f1-2a3b-4c5d-9e6f-0a1b2c3d4e5f",
"event_type": "trace.sealed",
"content_hash": "3b428d41452a757b5466edc0520d0f3028c0335cfab5c7b6dac47531dbfcf321",
"timestamp": "2026-09-22T09:01:54Z",
"signature": "e1f3a5b7c9d0f2a4b6c8d1e3f5a7b9c0d2e4f6a8b1c3d5e7f9a0b2c4d6e8f1a3b5c7d9e0f2a4b6c8d1e3f5a7b9c0d2e4f6a8b1c3d5e7f9a0b2c4d6e8f1a3b5c7",
"public_key": "1f8e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0"
},
"verification": {
"composite_hash_valid": true,
"all_signatures_valid": true
}
}
/v1/tracesCreate a traceGET/v1/tracesList tracesGET/v1/traces/{trace_id}Get a traceDELETE/v1/traces/{trace_id}Delete an empty tracePOST/v1/traces/{trace_id}/sealSeal a traceGET/v1/traces/{trace_id}/proofGet the proof bundleGET/v1/traces/{trace_id}/proof/pdfDownload the proof bundle as PDFGET/v1/proof/trace/{trace_id}Get the public proof