Authenticate a document as your organisation
Authenticates a document as the calling organisation, signing its hash with the organisation's own post-quantum key and anchoring the result to Hedera. This is the only operation on this API that creates anything.
Send a hash. Audrie never receives the document.
A
test-prefixed credential creates a sandbox record. It signs with your organisation's sandbox key — separate from your production key — and anchors to Hedera's test network, never mainnet. A sandbox record verifies only assandbox, neververified_org, carries no legal weight, and we delete our sandbox record of it after 30 days; its hash remains on the Hedera test network, which we do not control.A hash you authenticate with a
testkey answerssandboxto any caller who submits it, anonymous callers included, for as long as the sandbox record exists — do not authenticate a hash of a document whose existence is confidential. Mint alivecredential when you are ready to create records that carry legal weight.
What this is, and what it is not
The signature attributes to an organisation, not to a person. No
individual is accountable for it: there is no identity-verified signer
and no WebAuthn ceremony behind an API call. A verifier sees that
difference — these records answer verified_org rather than verified,
carry signerType: org on the match, and at the organisation tier
carry your organisation's registered name in signerOrg.
Use it where an organisation is the right signer: bulk authentication of documents your organisation issues. Where a named person must be accountable, have that person authenticate in the portal instead.
Idempotency
Retrying is safe, and you do not need an idempotency key: the document hash is the key.
Your organisation can hold one active record per document hash. A
repeat call for a hash you already hold returns that record with 200
and creates nothing; a first call returns 201. The body is identical
either way, because after a timeout you cannot know which call you made
— branch on the status only if you care.
A record whose anchor ultimately failed stops counting, so a later call for that hash creates a new record rather than returning the failed one forever.
Anchoring is asynchronous, and it can fail
On a 201, anchorState is pending or accepted, and reaches
anchored at Hedera finality — typically 3–5 seconds. On a 200 retry
it is whatever the existing record has already reached, which may be
anchored. Branch over all three values, not two. Poll
POST /v1/verifications with the same hash rather than this operation.
Read the verdict, not the clock, and expect three outcomes. Until the
record reaches the ledger, verification answers no_record — it reports
only records that have been accepted for anchoring, so a record you have
just created is briefly invisible there. Then:
| The verdict becomes | Meaning | What to do |
|---|---|---|
pending_anchor | On the ledger, awaiting consensus. | Wait retryAfterSeconds and poll again. |
verified_org | Anchored. Done. A record created with a test key reads sandbox here instead. | Nothing. |
still no_record after ~3 minutes | The anchor failed before the message was submitted. The record is abandoned and stops being active. | Call POST /v1/records again for this hash. It will create a new record rather than returning the abandoned one. |
stuck at pending_anchor for many minutes | The message reached the ledger but consensus has not been reconciled. This one does not self-resolve by retrying, and re-authenticating will not help — the record is still active, so a repeat call returns it unchanged. | Report it with the recordId. |
An anchored record cannot be withdrawn. A Hedera message is
permanent. Revocation marks a record as no longer in good standing
rather than removing it — see
POST /v1/records/{recordId}/revoke.
Errors
A 400 means originalDocHash is not sha256: followed by 64
lowercase hex characters, filename is longer than 500 characters or
contains a control or invisible formatting character, or the body is not
a JSON object.
error.details names the field. Uppercase hex is refused rather than
normalised: the hash is used verbatim inside the signature and the ledger
message, so two spellings would be two separate permanent records for one
document.
The 402 and 403 each have more than one cause. Branch on
error.code, and read the response descriptions below — they say which
causes exist and which of them mean "wait" rather than "fix something".
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
What to authenticate. It names no organisation and no signer: both come from the API credential you present.
There is no orgId or credentialId field, and there never will be.
Sending one is not an error — unknown properties are ignored — but it has
no effect whatsoever. Tenancy is taken from the credential and from
nothing else, so a key can only ever write records for its own
organisation.
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}$filename?stringOptional label stored with the record. At most 500 characters. Control characters are refused, and so are invisible formatting characters generally — for example bidirectional overrides and isolates, the soft hyphen, the zero-width space and the byte-order mark — because this text is rendered on a public page and those characters can make a name display as something it is not.
Two exceptions: the zero-width joiner and non-joiner are permitted, because Persian and several Indic scripts require them to spell words correctly and emoji sequences use them. Accented and CJK filenames are fine; the rule is about invisible characters, not about alphabet.
It is not private. Do not put confidential text in it. It is
shown on the record's verification page — the verifyUrl this
operation returns — which needs no credential, so anyone who has that
link or the recordId can read it. A client name, a matter
reference or a deal codename in a filename is disclosed to whoever
you share the link with, and to anyone they share it with.
It is metadata and nothing more: it is not the document, it is not
hashed, and it does not appear in the signature or on the ledger. It
is NOT returned by the verification endpoints — matches[] carries
no filename at any disclosure tier.
length <= 500Your organisation already holds an active record for this hash. It is
returned unchanged and nothing was created. The body is the same
shape as the 201.
A repeat call updates nothing — a different filename in the
body is ignored, not applied. If a filename you have already
published needs changing, re-sending will not do it: the record is
anchored, and correcting it is a support request.
application/json- response
A document authenticated by your organisation.
It carries no kind and no signerType: every record this operation
produces is a record_only record signed by the organisation's system
key. Those fields are published per match on the verification
endpoints, where they vary.
recordId*stringAudrie's identifier for this record. Stable and permanent; store it.
uuidoriginalDocHash*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}$authenticatedAt*stringWhen the record was authenticated, RFC 3339 UTC. This is the signed time, not the ledger's consensus time — the two differ by seconds and the consensus time is published on the verification endpoints.
date-timeanchorState*AnchorStateHow far a record has got towards Hedera finality. Treat any value you do not recognise as not yet final.
| Value | Meaning |
|---|---|
pending | The record exists and its ledger message has not been confirmed as submitted. |
accepted | The ledger accepted the message and consensus is seconds away. |
anchored | Consensus reached. The record is final and permanent. |
This field is Audrie's own record of progress, not the ledger's, and
so is the verdict you get back from verifying the hash — checksPerformed
says which of its fields are cryptographic recomputations and which
report stored state, and the anchor status is stored state.
pending and accepted plainly mean "not yet". To confirm anchored
against the ledger itself, resolve the match's anchor.mirrorNodeUrl
against a public Hedera mirror node — that is the one answer that does
not rest on anything Audrie wrote down.
POST /v1/records's Anchoring is asynchronous, and it can fail section
is keyed on the VERIFICATION verdict rather than on these values, and
includes the case that does not resolve by waiting.
This is a different question from ChecksPerformed.anchorStatus, which
reports whether a check passed. This reports progress.
"pending""accepted""anchored"verifyUrl*stringA human-readable verification page for this record. It needs no credential, so treat it as public: anyone you send it to, and anyone they send it to, can open it.
It shows the filename you supplied. See the warning on
filename in the request schema before putting anything
confidential there.
urianchor*Where the record is committed on Hedera.
There is no mirrorNodeUrl here, unlike on a verification match: at
the moment this response is written the message may not have reached
consensus, so a mirror node would answer 404 for it. topicId and
transactionId are the coordinates; POST /v1/verifications
publishes the resolvable URL once there is something to resolve.
retryAfterSeconds?integerSeconds to wait before verifying, present while anchorState is not
anchored. Absent once the record is final.
curl -X POST 'https://api.audrie.io/v1/records' \ -H 'Content-Type: application/json' \ -d '{ "originalDocHash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}'{ "recordId": "9a1b7c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d", "originalDocHash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "authenticatedAt": "2026-09-14T03:21:44Z", "anchorState": "anchored", "verifyUrl": "https://app.audrie.io/verify/9a1b7c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d", "anchor": { "topicId": "0.0.4915623", "transactionId": "0.0.1234@1746223981.000000000", "hashscanUrl": "https://hashscan.io/mainnet/transaction/0.0.1234@1746223981.000000000" }}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.
Revoke a record your organisation authenticated POST
Marks a record your organisation authenticated as no longer in good standing, and anchors that decision to Hedera. **Revocation is permanent.** It cannot be undone, by you or by us. The record is not removed — a Hedera message cannot be withdrawn — and verification continues to report it, with a `revoked` verdict and the reason you gave. ## The credential must carry `records:revoke` This scope is granted only when you ask for it as you mint a credential. A credential that can authenticate documents cannot revoke them unless you requested both, and a credential minted without it never gains the scope — mint a new one. Ask for it on the credentials you intend to revoke with, and not on the ones your batch jobs carry. A credential holding this scope can revoke **any** record your organisation authenticated, including records created by a different credential. That is what makes it the right tool after a credential is lost: mint a new credential with `records:revoke` and clean up with it. **A credential granted `records:revoke` also carries `records:write`.** There is no revoke-only credential, so a key you mint to clean up after an incident can also authenticate documents as your organisation — protect it as a signing key, and revoke it when the cleanup is done. A `test`-prefixed credential can be granted this scope, exactly as a `live` credential can, and it revokes only its own organisation's sandbox records. Naming a live `recordId` finds nothing to revoke and answers `404`, and the same is true in reverse — a `live` credential naming a sandbox `recordId` also gets `404`. ## What you can revoke Records your **organisation** authenticated through this API. Documents a person authenticated in the Audrie app are not revocable here — only that signer can revoke those, from the app. Revoking is not a way to correct a mistake in the document itself. A corrected document is a new document with a new hash: authenticate the new one, and revoke the old one as `SUPERSEDED`. ## Errors A `403` has two causes, and they read differently. A credential without `records:revoke` is told so, and the message names the scope. Otherwise the record is not one your organisation can revoke — and that answer deliberately does not distinguish a record belonging to another organisation from one a person authenticated in the Audrie app. A `409` has two causes and does not distinguish them. **Do not retry blindly**: if the record is already revoked, retrying never succeeds, because revocation is permanent. If its anchor has not yet reached consensus, retry once `POST /v1/verifications` reports `verified_org` — or, for a `test` key's own sandbox record, `sandbox`. That call is also how you tell the two apart — an already-revoked record reports `revoked`, with a `live` or a `test` key.