Verify a document hash
Submits a document hash and returns a verdict — Audrie's answer to "does this document check out, and what do you know about it".
Both cryptographic checks are recomputed server-side for your request. Nothing in the request body is trusted beyond the identifiers used to look records up: a client that lies about a hash can only produce a verdict that fails its own verification.
Works with or without a credential. See Disclosure tiers.
What you get back
verdict is a single value you can branch on. It is derived from the
four checks in matches[].checksPerformed plus revocation state, and it
fails closed: anything Audrie cannot fully substantiate is never reported
as verified.
Every determinate outcome — no_record included — returns 200 with a
verdict. A hash Audrie holds no record of is information, not a failed
request.
mode says what the verdict was computed over. Read the field rather
than hardcoding a value.
Two modes
partial is the default: omit mode, or send partial explicitly,
and Audrie looks up every anchored record registered against
originalDocHash. This is the mode described above — one hash, zero or
more records.
full re-verifies ONE exact certified PDF you already hold. Send
mode: full together with recordId (the record id embedded in that
PDF) and signedDocHash (prefixed sha256:). signedDocHash is the
SHA-256 of the exact bytes you are holding with each signer's
signature value replaced by the same number of ASCII 0 characters.
A signer's signature value is the hex text of signers.<alg>.sig in
the embedded certificate, without its quotes; Audrie's seal signature
(serverSeals.<alg>.sig) is hashed unchanged. It is not the
sha256sum of the file: a signer's signature cannot cover a hash that
includes itself. Audrie's verifier computes it when it reads a
certified PDF; sha256sum cannot, because each signer's signature
value has to be located before it is blanked. originalDocHash stays the
plain sha256sum of the original file. recordId and signedDocHash
are required with full and rejected with partial — a request
naming both a mode and fields that mode does not use is refused with
400, not silently corrected.
A full response carries at most one match — the exact record you
named — and matches[] is empty for every collapsed outcome below.
A recordId that does not exist, one that names a document Audrie
did not embed a certificate in, and a recordId whose hashes do not
match the ones you sent all answer the same way: 200 with
verdict: no_record and an empty matches[], identical in every
field but verifiedAt, which records when the request was answered
and carries no information about which cause applied. Response
timing is not part of that guarantee either.
recordId is not a secret — it appears in the Hedera messages an
authenticated document anchors to — so treating any of these three
causes differently in the response body would let anyone holding a
recordId alone learn whether it exists and what kind of record it
is, with no proof of possessing the document at all. Do not build
logic that expects to tell these three apart from the response; a
well-formed recordId reused across requests returns a body
identical in every field but verifiedAt (which records when the
request was answered and carries no information about which cause
applied), regardless of which cause applies.
A malformed recordId — one that is not a UUID — is the one exception,
and answers 400: it is a syntax error you can detect yourself before
sending the request, not a fact about whether a record exists.
pending_anchor is answered honestly. It is reachable only once
originalDocHash and signedDocHash are both confirmed against the
named record, so by the time you see it you already hold a document
whose bytes (apart from each signer's signature value) and record id,
we have just told you, are correct —
the reply adds nothing an attacker without that document could not
already infer.
One hash, several records
partial mode does not let you pick the record. One hash can
legitimately have several records — a re-signing, or several parties
authenticating the same document — so the response returns every
anchored match, ordered by anchor time, earliest first. The timeline is
the answer.
verdict asserts that some valid anchored authentication exists, not
that a particular party made it. Read each match's own status,
signerStanding and signerHint before attributing a document to
anyone.
Signer standing is live
signerStanding is recomputed at request time. A certificate issued
earlier carries a frozen snapshot; this endpoint does not. The two can
legitimately disagree, and the live value is the current answer.
Errors
A 400 here means originalDocHash or signedDocHash is not
sha256: followed by 64 lowercase hex characters, reference is
longer than 128 characters or contains a control character, mode is
not partial or full, recordId/signedDocHash are sent with
partial or missing with full, recordId is not a UUID, or the body
is not a JSON object. error.details names the field.
apiKeyAuthorizationBearer <aud_{live|test}_<keyId:16><secret:32><crc:6>>Authorization: Bearer aud_live_…
Optional on POST /v1/verifications, which answers without any
credential. Required to list or read verifications back.
Send exactly one Authorization header. A credential that is present and
does not work is refused rather than downgraded to the anonymous tier.
Issue a key in the portal under Settings → Organisation → API credentials; it is shown once, at creation.
Three scopes exist. verifications:read covers every verification
operation and is on every key. records:write covers
POST /v1/records, and records:revoke covers
POST /v1/records/{recordId}/revoke. Both are granted to a live or a
test credential alike, on an organisation that holds the
organisation-issued authentication add-on at the moment the key is
minted; records:revoke is granted only when you ask for it. Scopes
are fixed then and never widen afterwards, because a credential's
recorded scopes are what an auditor reads to know what that key could
do on a given date. A test key's records:write and records:revoke
reach sandbox records only: signed with the organisation's sandbox key,
anchored on Hedera's test network, and never a live record. A key minted
without a scope never gains it; mint a new one. Buying the add-on later
means minting a new key too; the old one keeps verifying.
Scopes, rotation, revocation, the live and test prefixes and what to
do about a leaked key:
https://audrie.io/docs/authentication
application/json- body
The document hash to verify, and an optional identifier of your own.
Add mode: full to re-verify one exact certified PDF by record id
instead — see mode.
originalDocHash*stringSHA-256 of the original document, before any Audrie certificate page
was attached. Compute it yourself — sha256sum contract.pdf — and
prefix it with sha256:.
^sha256:[0-9a-f]{64}$mode?stringWhich verdict to compute. Defaults to partial.
| Value | Meaning |
|---|---|
partial | Look up every anchored record registered against originalDocHash. recordId and signedDocHash are rejected with this mode. |
full | Re-verify one exact certified PDF, named by recordId. Requires recordId and signedDocHash. |
Omitting mode is identical to sending partial.
"partial""full"recordId?stringThe record id embedded in the certified PDF you are verifying —
required with mode: full, rejected with partial. A malformed
value (not a UUID) is rejected with 400; a well-formed one that
does not exist returns the same response as a hash mismatch — see
full mode in this operation's description.
uuidsignedDocHash?stringSHA-256 of the exact certified PDF bytes you are holding, with
the hex text of each signer's signature value (signers.<alg>.sig
in the embedded certificate, without its quotes) replaced by the
same number of ASCII 0 characters. Audrie's seal signature
(serverSeals.<alg>.sig) is hashed unchanged. This is not the
sha256sum of the file, and it is not computed the way
originalDocHash is. Audrie's verifier computes it when it reads
a certified PDF. A sha256sum of the file, or a hash with the seal
signature blanked as well, is well formed once prefixed sha256:
and answers no_record.
Required with mode: full, rejected with partial.
^sha256:[0-9a-f]{64}$reference?stringYour own identifier — a matter number, case id, or batch tag. Up to 128 characters, any script. Control characters are refused.
Stored and returned verbatim, filterable when listing, never interpreted. Do not put personal data here.
Ignored on anonymous requests, which are not logged. Stored but not
echoed back while your organisation is being validated, because those
responses are public tier.
length <= 128A determinate verdict. Read verdict; no_record and
pending_anchor arrive here rather than as errors.
application/json- response
A verdict, and the records behind it. Which fields are populated depends
on disclosureTier.
verificationId?stringStable id for this verdict — use it to read the verdict back and to
reconcile. A UUID. organisation tier only.
An anonymous verification is attributable to no one and is not
retained, so it has none. A credentialed request from an organisation
being validated is served at the public tier and so carries none
either — but it is attributed to your organisation and retained, and
appears in your log once validation completes.
uuidverdict*VerdictAudrie's answer about the submitted document. It fails closed: anything we
cannot fully substantiate is never verified. Treat any value you do not
recognise as not verified.
| Value | Meaning | What to do |
|---|---|---|
verified | An anchored record matches, every check passed, and the signature attributes to a person. | Read each match's signerStanding and signerHint before accepting. The verdict is about the document, not about who authenticated it. |
verified_org | An anchored record matches and every check passed, and the signature attributes to an organisation's system key rather than to a person. | Accept only if a signature made under an organisation's authority, with no named person accountable for it, satisfies your requirement. Read matches[].signerType to see which records are which, and at the organisation tier matches[].signerOrg for which organisation. |
revoked | A record matches and is no longer in good standing, or Audrie holds a revocation for this hash beyond the 25 records matches returns. | Read revocation.reason when a revoked record appears in matches — it may not. A superseded document was still validly signed at the time. |
signature_invalid | A record matches and its stored signature failed re-verification. | Escalate. Do not retry. |
pending_anchor | The hash is registered and awaiting Hedera finality. | Retry after retryAfterSeconds. Finality is 3–5 seconds. |
no_record | Audrie holds no record of this document. | Treat as unknown, not as an error. |
sandbox | The only record matching this hash was authenticated with a test credential, anchored on Hedera's test network rather than the network a genuine record uses. | Treat exactly like no_record for any trust decision. This is never an authentication: it names no signer, and any organisation holding a test credential can produce it for any hash you send. |
A document authenticated moments ago answers no_record for the short
window before its record is submitted to Hedera, then pending_anchor
until finality. If you verify immediately after authenticating, retry
rather than concluding the record was lost.
Where several records match one hash, the verdict is a single reduction across all of them. This is the precedence, and the first value any match produces is the answer:
signature_invalid → verified → verified_org → revoked →
pending_anchor
So one tampered record is enough to answer signature_invalid, and one
healthy anchored authentication is enough to answer verified or
verified_org. Between those two, a match signed by a person outranks
one signed by an organisation's system key: a hash carrying both answers
verified, and verified_org means every healthy match was signed by an
organisation.
The scalar therefore asserts only that SOME match produced the winning
value. A verified or verified_org answer does not mean every
match is healthy — a revoked record for the same hash is invisible in
it — and it attributes the document to nobody. Read each match's own
status and signerType rather than inferring either from the scalar.
sandbox sits outside that reduction, because a live record for a hash
always answers first. An anonymous caller or a live credential sees
sandbox only when Audrie's check for a live record finds none already
accepted for anchoring against this hash. A record you have just
authenticated — before Hedera accepts it — or one whose submission
failed, is not visible to that check, so a sandbox record for the same
hash can answer sandbox in that window. In full mode, this check is
keyed by the recordId you send, not by the hash. The answer carries
matches: [] and matchesTruncated: false — no anchor, signer or
revocation detail, whatever state the sandbox record is actually in. A
test credential sees only sandbox records: reading its
OWN organisation's sandbox record for a hash, it gets sandbox in place
of what would otherwise be verified or verified_org, in full match
detail, with revoked, signature_invalid and pending_anchor
reported as themselves so it can exercise every branch. Another
organisation's sandbox record reads to a test credential exactly as it
reads to an anonymous caller — collapsed to sandbox, with no detail.
A sandbox record does not last: we delete our sandbox record of it after 30 days; its hash remains on the Hedera test network, which we do not control.
"verified""verified_org""revoked""signature_invalid""pending_anchor""no_record""sandbox"disclosureTier*DisclosureTierHow much this response was permitted to disclose. Read it rather than
inferring it from whether you sent a key. Treat any value you do not
recognise as public.
| Value | Meaning |
|---|---|
public | Served without a credential, or with a credential on an organisation that is still being validated. Carries the verdict, every check, the anchor proof, revocation state and signer standing. |
organisation | Served with a credential on a validated organisation. Adds verificationId, environment, your reference and matches[].signerOrg on organisation-signed matches, and admits you to the reconciliation log. |
Disclosure tiers has the full comparison.
"public""organisation"assuranceLevel*"audrie.registry.v1"How strong this verdict is. Always present.
audrie.registry.v1 means the verdict was recomputed server-side from
the anchored registry, with no certificate artifact issued. Read the
field explicitly rather than hardcoding the value, and never treat its
absence as a default.
"audrie.registry.v1"mode*stringWhat the verdict was computed over — see mode on the request.
| Value | Meaning |
|---|---|
partial | Computed from every anchored record matching the document hash you submitted. |
full | Computed by re-verifying one exact certified PDF, named by recordId in the request. |
GET /v1/verifications/{verificationId} and the list operation
return the mode recorded for that verification, not just
POST /v1/verifications's immediate response.
"partial""full"environment?stringThe environment of the key that produced this verdict, taken from its
prefix. organisation tier only.
| Value | Meaning |
|---|---|
live | Produced by an aud_live_ key. |
test | Produced by an aud_test_ key. |
The two are isolated, not ranked: a test key never sees live
verifications and neither is a permission level. A verification
recorded under test is checked only against sandbox records:
your own organisation's in full detail, and another
organisation's collapsed to the sandbox verdict (see
Verdict).
"live""test"originalDocHash*DocHashA SHA-256 digest — sha256: followed by 64 lowercase hex characters.
Uppercase hex and a bare digest with no prefix are both refused.
^sha256:[0-9a-f]{64}$verifiedAt*stringWhen Audrie computed this verdict. RFC 3339 timestamp, UTC.
date-timereference?stringEchoed verbatim from the request. organisation tier only.
matches*array<>Anchored records matching the hash, ordered by anchor time, earliest
first. Empty for no_record and pending_anchor, and also for
revoked when the revocation lies beyond the 25-record window
described below.
mode: partial returns at most 25 records, and verdict is
computed over the records returned, plus — for revoked only — a
check that also covers records beyond these 25. A hash reaches 25
only when many parties independently authenticate identical bytes;
if that is your use case, contact us.
verdict can be revoked even when no revoked record appears
here: Audrie checks for a revocation on this hash beyond the 25
records returned. That beyond-window revocation reaches verdict
only when none of the returned matches already yields
verified, verified_org or signature_invalid — those outrank
it. When one does, the revocation surfaces nowhere in the
response; matchesTruncated: true is the only signal that the
list may be hiding one. Read matchesTruncated to see whether
this list itself may be incomplete.
mode: full returns at most one record — the one named by
recordId in the request. no_record here means the id, kind and
hashes were not all confirmed together; see full mode in the
operation's description for why those causes are not
distinguished.
matchesTruncated?booleanWhether the underlying match lookup reached the 25-record limit
and may hold more records than matches shows. false means the
lookup is complete. It does not say how many further records
exist, only that there may be more — and it can be true while
matches is empty: a revocation beyond the 25-record window can
turn verdict revoked without any of the returned records
being the revoked one. Always false on mode: full, which
resolves exactly one record and has no 25-record window to reach.
Present only on POST /v1/verifications, which recomputes
matches and can answer definitively. Reading a recorded verdict
back — GET /v1/verifications/{verificationId} and the list
operation — never recomputes matches, so both omit this field
rather than assert a stale answer.
retryAfterSeconds?integerSeconds to wait before retrying. Present on a pending_anchor
verdict from POST /v1/verifications, and on no other verdict.
Reading a recorded pending_anchor verdict back does not carry it.
curl -X POST 'https://api.audrie.io/v1/verifications' \ -H 'Content-Type: application/json' \ -d '{ "originalDocHash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}'{ "verdict": "verified", "disclosureTier": "public", "assuranceLevel": "audrie.registry.v1", "mode": "partial", "originalDocHash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "verifiedAt": "2026-08-21T04:11:09Z", "matches": [ { "recordId": "8f7d2c11-2b3e-4a5f-9c8d-1e2f3a4b5c6d", "kind": "pdf_embedded", "status": "active", "recordIssuedAt": "2026-05-02T22:14:51Z", "signerType": "user", "signerStanding": "good_standing", "signerHint": "Wren & Associates LLP", "checksPerformed": { "hashBinding": "pass", "userSignature": "pass", "serverSeal": "pass", "anchorStatus": "anchored" }, "anchor": { "topicId": "0.0.4915623", "transactionId": "0.0.1234@1746223981.000000000", "sequenceNumber": 8814, "consensusAt": "2026-05-02T22:14:51.221Z", "mirrorNodeUrl": "https://mainnet-public.mirrornode.hedera.com/api/v1/transactions/0.0.1234-1746223981-000000000" } } ]}List your organisation's verifications GET
Returns your organisation's verification log, newest first. Scoped to the calling key's organisation and environment: a `test` key never sees `live` verifications. Anonymous verifications are attributable to no one and are not logged, so a credential is required. Rows carry the verdict as it was recorded. They carry no `matches`: to get matches for a hash, `POST` it, which is the operation that computes them. Cursor-paginated. See [Pagination](https://audrie.io/docs/pagination). ## Errors Every query parameter is rejected rather than ignored or clamped. An unparseable `from`, an out-of-range `limit` or a cursor that does not decode all answer `400` with the offending field in `error.details` — a silently different page is worse than a refusal on a log you reconcile against.
Read one verification back GET
Returns one verification as it was recorded. Scoped to the calling key's organisation and environment. `matches` is always `[]` here. Matches are derived from the document hash rather than stored, so this operation answers "what did we tell you, and when". To get current matches, `POST` the hash again, which is the operation that computes them. ## Errors A `404` means the id does not exist, belongs to another organisation, or belongs to another environment. These are not distinguished, and a malformed id answers the same way. Existence is not disclosed.