Documents
Anchor the SHA-256 hash of a file to a signed, append-only ledger record and check a file against it later.
A document record is a SHA-256 hash that your tenant anchored at a point in time, with an optional human-readable reference, an event type, a JSON metadata object and, if you choose to upload it, the original file bytes. You compute the hash on your side; the backend never needs the file to anchor it.
On anchor the backend checks the hash format, the metadata size, the optional original bytes and the optional trace, reserves a document from your monthly quota, and publishes the record to a writer queue. The writer signs a payload made of the tenant id, the API key id, the event type, the hash and the anchor timestamp with the tenant's Ed25519 key, stores the row in the append-only event ledger, and stores the original in object storage when one was sent.
To check a file later, hash it again and call verify; the backend compares the submitted hash with the anchored one and returns both. To check the signature itself, fetch the record and verify signature_b64 over signed_payload_b64 with public_key_b64, or use the public proof page for the event id.
document_hash | exactly 64 hexadecimal characters (a SHA-256 digest) |
|---|---|
| metadata | up to 8,192 bytes once serialized as JSON |
original_bytes_b64 | 1 byte to 5 MiB after base64 decoding, and the decoded bytes must hash to document_hash |
| Request body | 8,462,336 bytes (about 8.1 MB) on the anchor route, sized so a 5 MiB original fits once base64-encoded, plus metadata and the envelope; the other document routes keep the 2 MB framework default |
| List page size | 1 to 500 per page, default 50; values outside the range are clamped, not rejected |
| Documents per month | the plan's documents_per_month, consumed on every anchor call including ones later rejected with 400 or 409 |
| Verifications per month | the plan's api_verifications_per_month, consumed on every verify call with a well-formed hash |
| Rate limit | the plan's rate_limit_per_sec per tenant (default 10) and 60 times that per minute, shared by all keys of the tenant |
| Idempotency-Key | one completed response is replayed for 24 hours; an in-flight reservation lasts 120 seconds |
/v1/document/anchorAnchor a document
Anchors a SHA-256 hash, with optional reference, type, metadata, original bytes and trace, and returns the new event id once the record is queued for signing.
Idempotency-KeyOptional key that makes retries safe; the same key with the same body replays the first response for 24 hours.
document_hashSHA-256 digest of the file; trimmed and lowercased before validation and storage.
document_refHuman-readable label for the document, such as the file name.
event_typeClassification stored with the record and included in the signed payload.
original_bytes_b64The file itself, standard base64 with padding; the decoded bytes must hash to document_hash and are stored for later download.
metadataArbitrary JSON stored next to the record and returned by get.
trace_idId of an open trace owned by the tenant; the document is attached to it and the trace must not be sealed or sealing.
- The 201 is returned once the record is on the writer queue; the ledger row, signature and stored original are written shortly after, so a get immediately afterwards can return 404 document_not_found for a moment.
- The monthly document quota is incremented before validation and before the duplicate check, so a request that ends in 400 or 409 still consumes one document.
- The duplicate-hash check runs before the idempotency replay: a retry with the same Idempotency-Key gets the cached 201 only while the first request is still being written, and 409 document_already_anchored once the row exists.
- A retry while the first request is still in flight under the same Idempotency-Key returns 202 with a body of {"status": "processing"}; that reservation expires after 120 seconds and a completed response is replayed for 24 hours.
- original_bytes_b64 is validated after the idempotency reservation is taken and a rejection there does not release it, so a request that fails with one of the original_bytes errors leaves its Idempotency-Key reserved for up to 120 seconds; a retry in that window gets 202 processing.
- Idempotency-Key is trimmed and ignored when empty; unlike the events endpoint, this endpoint enforces no length limit on it.
- The anchor route sets its own request body limit of 8,462,336 bytes, sized for a 5 MiB original once base64-encoded plus metadata and the envelope; an original just over 5 MiB still reaches the handler and gets original_bytes_too_large, while a body over the route limit gets 413 payload_too_large in the JSON error shape.
- original_bytes_b64 must be standard base64 with padding; the URL-safe alphabet is rejected with invalid_original_bytes_base64.
- document_hash is trimmed and lowercased, so uppercase hex is accepted and stored lowercase; the uniqueness check is per tenant.
- There is no retention field on this request; the retention applied to the record and its original comes from the plan's retention_days and is set by the writer.
- created_at is serialized with nanosecond fraction and a +00:00 offset, for example 2026-09-22T08:14:07.512345678+00:00.
- A body that is not valid JSON, does not match the schema or is sent without a JSON Content-Type gets invalid_json (400), invalid_request (422) or unsupported_media_type (415) in the JSON error shape.
- Every SDK also has a file helper (anchorFile, anchor_file, AnchorFile or AnchorFileAsync) that reads a file, computes the hash, base64-encodes the bytes into original_bytes_b64 and calls this endpoint; pass skipOriginal or skip_original to anchor the hash only.
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { InvoanceClient } from "invoance";
const file = readFileSync("./INV-2026-0917.pdf");
const documentHash = createHash("sha256").update(file).digest("hex");
// Reads INVOANCE_API_KEY from the environment.
const client = new InvoanceClient();
const result = await client.documents.anchor({
documentHash,
documentRef: "INV-2026-0917.pdf",
eventType: "invoice.issued",
metadata: { invoice_number: "INV-2026-0917", amount: 5230, currency: "USD" },
idempotencyKey: "anchor-" + documentHash,
});
console.log(result.event_id, result.status);
{
"event_id": "7c1e4b52-9a0f-4d2e-b6f3-2f8a61c0d9e4",
"created_at": "2026-09-22T08:14:07Z",
"document_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"status": "accepted"
}
event_idId of the new document record; use it with get, original, verify and the public proof page.
created_atWhen the request was accepted; also the ts value inside the signed payload.
document_hashThe hash as stored, lowercase.
statusAlways accepted; the ledger row is written by the writer shortly after. One of accepted.
missing_api_keyNeither an Authorization header nor an X-API-Key header was sent.
invalid_authorization_schemeThe Authorization header is present but does not start with Bearer.
invalid_api_key_formatThe key does not start with invoance_live_.
invalid_api_keyNo active key matches the value sent.
api_key_revokedThe key exists but has been revoked.
ip_not_allowedThe key has IP rules and the caller's address is not on the list.
rate_limitedThe tenant exceeded its plan's requests per second or 60 times that per minute; Retry-After is set.
api_key_lookup_failedThe key could not be looked up in the database.
insufficient_scopeThe key does not have the write scope.
invalid_api_contextThe authenticated context has no API key id; not expected for key-authenticated calls.
payment_requiredThe tenant's subscription is past due and the plan is not the free developer tier.
feature_not_availableThe plan's documents_per_month limit is 0.
quota_exceededThe tenant has used its documents_per_month for the current billing period.
invalid_jsonThe body is not valid JSON; the message carries the parser's position.
unsupported_media_typeThe Content-Type header is not application/json.
invalid_requestThe JSON does not match the request schema, for example a missing document_hash; the message names the field.
payload_too_largeThe whole JSON body is over the anchor route's limit of 8,462,336 bytes. A 5 MiB original fits; an original just over 5 MiB gets original_bytes_too_large instead.
invalid_document_hashdocument_hash is not exactly 64 hex characters after trimming.
invalid_metadatametadata could not be serialized; not reachable with valid JSON.
metadata_too_largemetadata serializes to more than 8,192 bytes.
trace_not_foundtrace_id was sent and no trace with that id belongs to the tenant.
trace_not_opentrace_id points at a trace whose status is sealed or sealing.
trace_invalid_statustrace_id points at a trace in a status other than open, sealed or sealing.
document_already_anchoredA record with the same document_hash already exists for the tenant.
idempotency_key_reuse_mismatchThe Idempotency-Key was already used with a different request body.
invalid_original_bytes_base64original_bytes_b64 is not valid standard base64.
original_bytes_emptyoriginal_bytes_b64 decodes to zero bytes.
original_bytes_too_largeoriginal_bytes_b64 decodes to more than 5 MiB.
original_bytes_hash_mismatchThe SHA-256 of the decoded original does not equal document_hash.
quota_check_failedThe plan limits or the Redis quota counter could not be read.
db_errorThe trace status lookup failed.
internal_errorThe duplicate-hash lookup failed.
hash_decode_failedThe validated hash could not be decoded; not expected.
serialization_failedThe queue envelope or the response could not be serialized.
ingest_failedPublishing to the writer queue failed; the idempotency reservation is released so a retry can re-ingest.
/v1/documentList documents
Returns a page of the tenant's active document records, newest first, with optional date and reference filters.
page1-based page number; values below 1 are raised to 1.
limitRecords per page; clamped into the allowed range rather than rejected.
date_fromOnly records anchored at or after this instant.
date_toOnly records anchored before this instant (exclusive).
document_refOnly records whose document_ref equals this value exactly.
- Only records with access_tier active are returned; sealed records past retention are left out of both documents and total.
- Ordering is anchored_at descending; there is no cursor, so page through with page and limit.
- date_from is inclusive and date_to is exclusive on anchored_at; a value that does not parse as an RFC 3339 timestamp is rejected by the framework with a plain-text 400.
- document_ref is an exact, case-sensitive match, not a prefix or substring search.
- Each distinct tenant, page, limit and filter combination is cached in Redis for 30 seconds, so a record anchored a moment ago can be missing until the cache expires.
- A key with the write scope passes the read check, so either scope can list.
- created_at is serialized with a fractional second and a Z suffix, for example 2026-09-22T08:14:07.512345Z.
import { InvoanceClient } from "invoance";
// Reads INVOANCE_API_KEY from the environment.
const client = new InvoanceClient();
const page = await client.documents.list({
limit: 25,
dateFrom: "2026-09-01T00:00:00Z",
});
console.log(page.total, page.has_more);
for (const d of page.documents) {
console.log(d.event_id, d.document_ref, d.has_original);
}
{
"documents": [
{
"event_id": "7c1e4b52-9a0f-4d2e-b6f3-2f8a61c0d9e4",
"document_ref": "INV-2026-0917.pdf",
"document_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"event_type": "invoice.issued",
"has_original": true,
"created_at": "2026-09-22T08:14:07Z"
}
],
"page": 1,
"limit": 50,
"total": 1,
"has_more": false
}
documentsThe records on this page, ordered by anchor time descending.
documents[].event_idId of the document record.
documents[].document_refThe reference given at anchor time, or Untitled.
documents[].document_hashThe anchored SHA-256 digest, lowercase.
documents[].event_typeThe type given at anchor time, or document_anchor.
documents[].has_originalTrue when an original was uploaded and stored.
documents[].created_atWhen the record was anchored.
pageThe page returned, after clamping.
limitThe page size used, after clamping.
totalNumber of active records matching the filters across all pages.
has_moreTrue when a later page exists.
missing_api_keyNeither an Authorization header nor an X-API-Key header was sent.
invalid_authorization_schemeThe Authorization header is present but does not start with Bearer.
invalid_api_key_formatThe key does not start with invoance_live_.
invalid_api_keyNo active key matches the value sent.
api_key_revokedThe key exists but has been revoked.
ip_not_allowedThe key has IP rules and the caller's address is not on the list.
rate_limitedThe tenant exceeded its plan's requests per second or 60 times that per minute; Retry-After is set.
api_key_lookup_failedThe key could not be looked up in the database.
insufficient_scopeThe key has neither the read nor the write scope.
db_errorThe count or data query failed.
/v1/document/{event_id}Get a document
Returns one document record with its hash, the signed payload, the Ed25519 signature and public key, the metadata and the issuing organization.
event_idId returned by anchor.
- To check the signature locally, base64-decode signed_payload_b64, signature_b64 and public_key_b64 and run an Ed25519 verify over the payload bytes with the 32-byte key; the payload's document_hash_hex must equal the hash of your file.
- A record past retention is sealed and returns 410 retention_expired rather than 404, so a client can tell an expired record from an unknown id; verify still works on sealed records.
- Each successful read writes a usage row (resource documents, action read) in the background; it does not consume a quota.
- A key with the write scope passes the read check, so either scope can read.
- created_at is serialized with nanosecond fraction and a +00:00 offset, for example 2026-09-22T08:14:07.512345678+00:00.
- domain_verified_at and logo_url are omitted from organization when they are null.
- The Node SDK types organization and metadata as optional and the Go, Rust and .NET SDKs as nullable, but the backend always returns organization and returns metadata as an object.
import { InvoanceClient } from "invoance";
// Reads INVOANCE_API_KEY from the environment.
const client = new InvoanceClient();
const doc = await client.documents.get("7c1e4b52-9a0f-4d2e-b6f3-2f8a61c0d9e4");
console.log(doc.document_hash, doc.has_original, doc.created_at);
console.log(doc.organization?.issuer_name, doc.organization?.domain_verified);
{
"event_id": "7c1e4b52-9a0f-4d2e-b6f3-2f8a61c0d9e4",
"tenant_id": "3f9d2c8e-6b41-4a7d-9e2f-8c1b5a7d0e63",
"document_ref": "INV-2026-0917.pdf",
"document_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"signature_b64": "RiqZH7z7hje5RxOS8WcCxs0i1ZjbKBKXrRRLXWCLzSM1CC/sFx9zkKcRl80jjI4O9RcOnsZ/RB1yfuBzo9PK+g==",
"signed_payload_b64": "eyJ2IjoyLCJ0ZW5hbnRfaWQiOiIzZjlkMmM4ZS02YjQx…",
"public_key_b64": "M/EMiSYgI9YYW5CkEgBnaHAuljw8cuyoaOUbcLH5OPQ=",
"has_original": true,
"metadata": {
"invoice_number": "INV-2026-0917",
"amount": 5230,
"currency": "USD"
},
"created_at": "2026-09-22T08:14:07Z",
"organization": {
"name": "Northwind Traders",
"issuer_name": "Northwind Traders Ltd",
"primary_domain": "northwind.example",
"domain_verified": true,
"domain_verified_at": "2026-08-30T11:02:45Z",
"logo_url": "https://cdn.invoance.com/logos/northwind.png"
}
}
event_idId of the document record.
tenant_idTenant that anchored the document; also inside the signed payload.
document_refThe reference given at anchor time, or Untitled.
document_hashThe anchored SHA-256 digest, lowercase.
signature_b64Base64 of the 64-byte Ed25519 signature over the exact bytes of signed_payload_b64.
signed_payload_b64Base64 of the JSON the tenant key signed: v, tenant_id, actor_type, actor_id, event_type, document_hash_hex and ts.
public_key_b64Base64 of the tenant's raw 32-byte Ed25519 public key; also published at the public keys endpoint.
has_originalTrue when an original was uploaded and stored.
metadataThe metadata object sent at anchor time, or an empty object when none was sent.
created_atWhen the record was anchored.
organizationThe issuing organization as recorded for the tenant that anchored the document.
organization.nameOrganization name.
organization.issuer_nameName the organization signs as.
organization.primary_domainPrimary domain of the organization.
organization.domain_verifiedTrue when the primary domain has passed DNS verification.
organization.domain_verified_atWhen the domain was verified; omitted when it has not been.
organization.logo_urlLogo URL; omitted when the organization has none.
missing_api_keyNeither an Authorization header nor an X-API-Key header was sent.
invalid_authorization_schemeThe Authorization header is present but does not start with Bearer.
invalid_api_key_formatThe key does not start with invoance_live_.
invalid_api_keyNo active key matches the value sent.
api_key_revokedThe key exists but has been revoked.
ip_not_allowedThe key has IP rules and the caller's address is not on the list.
rate_limitedThe tenant exceeded its plan's requests per second or 60 times that per minute; Retry-After is set.
api_key_lookup_failedThe key could not be looked up in the database.
insufficient_scopeThe key has neither the read nor the write scope.
invalid_path_parameterevent_id is not a UUID.
document_not_foundNo record with this id belongs to the tenant, or the writer has not stored it yet.
retention_expiredThe record is sealed because its plan retention window and grace period have passed.
db_errorThe lookup query failed.
/v1/document/{event_id}/originalDownload the original
Returns the file bytes that were uploaded with the anchor request, as an application/octet-stream body.
event_idId returned by anchor.
- The body is the file exactly as uploaded, not JSON; the response carries Content-Type application/octet-stream and Content-Disposition inline, and no file name.
- Only records with access_tier active are served, so a sealed record's original returns 404 document_not_found even though the record still verifies.
- Originals the writer stores in object storage are streamed on every call; originals stored in the database are cached in Redis for 5 minutes.
- This route uses the framework path extractor, so a non-UUID event_id gets a plain-text 400 rather than the JSON error shape.
- No quota is consumed and no usage row is written by this endpoint.
- The same handler also serves the dashboard at /v1/documents/{event_id}/original with session auth; the API path is the one above.
import { writeFileSync } from "node:fs";
import { InvoanceClient } from "invoance";
// Reads INVOANCE_API_KEY from the environment.
const client = new InvoanceClient();
const bytes = await client.documents.getOriginal("7c1e4b52-9a0f-4d2e-b6f3-2f8a61c0d9e4");
writeFileSync("./INV-2026-0917.pdf", Buffer.from(bytes));
console.log(bytes.byteLength);
<the original file bytes; Content-Type: application/octet-stream, Content-Disposition: inline>
missing_api_keyNeither an Authorization header nor an X-API-Key header was sent.
invalid_authorization_schemeThe Authorization header is present but does not start with Bearer.
invalid_api_key_formatThe key does not start with invoance_live_.
invalid_api_keyNo active key matches the value sent.
api_key_revokedThe key exists but has been revoked.
ip_not_allowedThe key has IP rules and the caller's address is not on the list.
rate_limitedThe tenant exceeded its plan's requests per second or 60 times that per minute; Retry-After is set.
api_key_lookup_failedThe key could not be looked up in the database.
insufficient_scopeThe key has neither the read nor the write scope.
document_not_foundNo original was uploaded for this record, the record does not belong to the tenant, or the record is sealed.
db_query_failedThe lookup query failed.
r2_fetch_failedThe object could not be read from object storage.
/v1/document/{event_id}/verifyVerify a document hash
Compares a SHA-256 hash you supply with the hash anchored under the event id and returns whether they match, both hashes and the anchor time.
event_idId returned by anchor.
document_hashSHA-256 digest of the file you hold now; compared byte for byte with the anchored digest.
- This compares hashes only; it does not check the Ed25519 signature and there is no signature_valid field. To check the signature, fetch the record with get and verify signature_b64 over signed_payload_b64 with public_key_b64.
- The lookup has no access_tier filter, so a sealed record past retention can still be verified even though get returns 410 and original returns 404 for it.
- Hash validation runs before the quota check, so a malformed document_hash returns 400 without consuming a verification; a well-formed one consumes one api_verifications_per_month whether or not it matches.
- Each call writes a verification_events row in the background with status verified or failed, source api and is_public false; it appears in the dashboard's verification history.
- The anchored hash, reference and organization are cached in Redis for 60 seconds per event_id.
- document_hash is decoded as hex without lowercasing, so uppercase input is accepted; submitted_hash comes back lowercase.
- A key with the write scope passes the read check, so either scope can verify.
- anchored_at is serialized with a fractional second and a Z suffix, for example 2026-09-22T08:14:07.512345Z.
- A body that is not valid JSON is rejected by the framework with a plain-text 400, 415 or 422 rather than the JSON error shape.
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { InvoanceClient } from "invoance";
const file = readFileSync("./INV-2026-0917.pdf");
const documentHash = createHash("sha256").update(file).digest("hex");
// Reads INVOANCE_API_KEY from the environment.
const client = new InvoanceClient();
const result = await client.documents.verify("7c1e4b52-9a0f-4d2e-b6f3-2f8a61c0d9e4", {
documentHash,
});
console.log(result.match_result, result.anchored_hash, result.anchored_at);
{
"event_id": "7c1e4b52-9a0f-4d2e-b6f3-2f8a61c0d9e4",
"match_result": true,
"document_ref": "INV-2026-0917.pdf",
"anchored_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"submitted_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"anchored_at": "2026-09-22T08:14:07Z",
"organization": {
"name": "Northwind Traders",
"issuer_name": "Northwind Traders Ltd",
"primary_domain": "northwind.example",
"domain_verified": true,
"domain_verified_at": "2026-08-30T11:02:45Z",
"logo_url": "https://cdn.invoance.com/logos/northwind.png"
}
}
event_idThe id that was checked.
match_resultTrue when submitted_hash equals anchored_hash.
document_refThe reference stored with the record.
anchored_hashThe digest stored at anchor time, lowercase.
submitted_hashThe digest you sent, re-encoded lowercase.
anchored_atWhen the record was anchored.
organizationThe issuing organization as recorded for the tenant that anchored the document.
organization.nameOrganization name.
organization.issuer_nameName the organization signs as.
organization.primary_domainPrimary domain of the organization.
organization.domain_verifiedTrue when the primary domain has passed DNS verification.
organization.domain_verified_atWhen the domain was verified; omitted when it has not been.
organization.logo_urlLogo URL; omitted when the organization has none.
missing_api_keyNeither an Authorization header nor an X-API-Key header was sent.
invalid_authorization_schemeThe Authorization header is present but does not start with Bearer.
invalid_api_key_formatThe key does not start with invoance_live_.
invalid_api_keyNo active key matches the value sent.
api_key_revokedThe key exists but has been revoked.
ip_not_allowedThe key has IP rules and the caller's address is not on the list.
rate_limitedThe tenant exceeded its plan's requests per second or 60 times that per minute; Retry-After is set.
api_key_lookup_failedThe key could not be looked up in the database.
insufficient_scopeThe key has neither the read nor the write scope.
invalid_path_parameterevent_id is not a UUID.
bad_requestdocument_hash is not exactly 64 hex characters.
payment_requiredThe tenant's subscription is past due and the plan is not the free developer tier.
feature_not_availableThe plan's api_verifications_per_month limit is 0.
quota_exceededThe tenant has used its api_verifications_per_month for the current billing period.
document_not_foundNo record with this id belongs to the tenant.
quota_check_failedThe plan limits or the Redis quota counter could not be read.
db_errorThe lookup query failed.
document_hash | The lowercase hex SHA-256 digest of the file, computed by the caller and stored as the identity of the record. |
|---|---|
document_ref | A free-text label for the document, such as a file name or invoice number; defaults to Untitled. |
event_type | A free-text classification stored with the record and included in the signed payload; defaults to document_anchor. |
| original | The file bytes uploaded with the anchor request, stored in object storage and served back by the original endpoint. |
has_original | True when an original was uploaded with the anchor and is available for download. |
anchored_at | The timestamp the ledger row records for the anchor; returned as created_at on most endpoints. |
| signed payload | The JSON the tenant key signed: version, tenant id, actor type and id, event type, hash and timestamp. |
| sealed | A record past its plan retention window plus grace; hidden from list, 410 on get, 404 on original, still verifiable. |
match_result | True when the hash submitted to verify equals the anchored hash byte for byte. |