Verify an AcreSeal record yourself — without our cooperation
Anyone can independently verify this photo by downloading it and running exiftool (to read its metadata) or shasum -a 256 (to confirm the bytes are unchanged). AcreSeal's participation is not required for verification.
Who this is for
This page is written for someone with a reason to doubt the evidence: opposing counsel, a claims adjuster, a regulatory auditor, or a court-appointed expert. Nothing here requires an account, an API key, or any cooperation from AcreSeal. If a step cannot be completed exactly as written, treat that as a finding.
You will need
A terminal with shasum (or sha256sum), jq, node, and openssl. exiftool is optional and only needed to read photo metadata.
shasum and openssl are preinstalled on macOS and Linux; jq and node are not, and several steps below need them.
On macOS, the preinstalled openssl is not enough. It is LibreSSL, and we tested every step against it:
- Ed25519 signature steps fail with
unable to load Public Key— it cannot read the key, let alone check the signature. - The RFC 3161 timestamp step fails with
unable to get local issuer certificateagainst the same trust store OpenSSL 3.x accepts.
So on macOS: use node for the signature steps (published alongside every openssl form below), and install OpenSSL 3.x for the timestamp step — brew install openssl, then call it by full path. Pass the system trust store, /etc/ssl/cert.pem: Homebrew's own bundle does not carry the timestamping chain and fails with ess cert id not found. On Linux the distribution openssl is normally 3.x and all steps work as written.
Part 1 — Verifying a record
A record is a single field report: a photo, its capture context, and an append-only audit chain. Verifying one establishes that the photo and its context have not changed since sealing.
Fetch the record
Replace <record-id> with the UUID you were given.
curl -s "https://acreseal.com/api/verify-chain?id=<record-id>&verify_env=true" -o record.json
jq '.valid, .authoritative, .broken_at' record.jsonExpect valid: true, authoritative: true, and broken_at: null.
authoritative is a property of the record, not of its id. Demonstration records — including, but not limited to, any id beginning demo- — return authoritative: false with demo: true. A record whose status could not be established returns authoritative: false with demo: false, and names the reason in authoritative_reason: absent is not the same as illustrative. Treat only authoritative: true as evidence.
Reproduce the photo hash
Download the stored evidence photo and hash it. This is the load-bearing check.
curl -s "$(jq -r '.photo.url' record.json)" -o evidence.jpg
shasum -a 256 evidence.jpg
jq -r '.photo.serverSha256' record.jsonThe two values must be identical.
Two hashes exist — compare against the right one
photo.serverSha256 is the digest of the bytes as stored, and is what a downloaded file reproduces. photo.sha256 is a capture-time digest computed in the browser before AcreSeal embedded its provenance EXIF, and a downloaded file will not reproduce it. If a record publishes only photo.sha256, it predates server-side re-hashing and no downloaded file can match it — that is a limitation of that record, disclosed rather than hidden. For the same reason, a camera original never matches: the stored file is not byte-identical to what left the camera.
Recompute the environmental hash
The record's forensic_hash binds the capture time, coordinates, weather at that place and hour, and the photo hash into one digest. The raw inputs are published so you can recompute it rather than trust our answer.
jq -r '[.env_context.timestamp, .env_context.lat, .env_context.lng,
.env_context.temp, .env_context.wind, .env_context.cloud,
.env_context.photoHash] | join("|")' record.json | tr -d '\n' | shasum -a 256
jq -r '.env_verification.stored' record.jsonThe fields are joined with | in exactly that order, with no spaces, and SHA-256'd. Absent values appear as the literal string null. Numeric fields are fixed-precision strings as published — do not re-round them.
Walk the hash chain
Each audit entry embeds the previous entry's hash, so any edit to history breaks every link after it.
jq '.entries[] | {action, forensic_hash, previous_hash, chain_valid}' record.jsonEvery entry should report chain_valid: true, and broken_at at the top level should be null. Immutability is enforced by database triggers, not by application code.
Optionally, read the photo metadata
Corroborating only — never treat EXIF as the integrity anchor.
exiftool evidence.jpgYou will see Software: AcreSeal Forensic Pipeline v1.0 and an AcreSeal provenance block. Original camera tags are carried forward when the file had them, but many will not: mobile operating systems strip metadata from photos captured through a browser, and files above 2 MB are re-encoded during compression. A record with no camera EXIF is normal and does not weaken the hash evidence.
Part 2 — Placing the record inside its seal
Part 1 shows a record is unaltered. Part 3 shows a seal was signed and timestamped. This part joins them: a Merkle inclusion proof demonstrating that this specific record is one of the leaves the seal covers — without you having to download every record in the job and search for it.
Read the inclusion block
curl -s "https://acreseal.com/api/verify-chain?id=<record-id>" -o record.json
jq '.inclusion | {status, leaf_index, leaf_count, merkle_root_hash, job_id}' record.jsonA status of proven means a proof is supplied. not_sealed means the record was never sealed into a job, so no third-party timestamp covers it. leaf_absent means the seal does not cover this record.
unverified is different in kind from all of those. Each of them is a finding about the record; this one means the check did not run, because an input could not be read on our side. It is not evidence that the record is unsealed — treat it as an absent answer, retry, and if it persists verify the job seal directly with the job_id the response still returns.
Fold the proof yourself
Hash the record's forensic hash as an RFC 6962 leaf, then fold in each sibling. An odd node at any level is promoted unchanged — not duplicated as in Bitcoin — which is why a tree of n leaves may contribute fewer than log₂(n) steps.
node -e '
const { createHash } = require("crypto");
const inc = JSON.parse(require("fs").readFileSync("record.json")).inclusion;
const sha = (b) => createHash("sha256").update(b).digest();
let h = sha(Buffer.concat([Buffer.from([0x00]), Buffer.from(inc.leaf_hash, "hex")]));
for (const s of inc.proof) {
const sib = Buffer.from(s.hash, "hex");
h = s.position === "left"
? sha(Buffer.concat([Buffer.from([0x01]), sib, h]))
: sha(Buffer.concat([Buffer.from([0x01]), h, sib]));
}
console.log(h.toString("hex") === inc.merkle_root_hash ? "INCLUDED" : "NOT INCLUDED");
'INCLUDED means the record is provably one of the leaves under merkle_root_hash. Carry that root into Part 3 to establish its place in third-party time.
If status is root_mismatch, treat it as a red flag. It means the job's current set of records no longer reproduces the root that was signed, so the set changed after sealing. No proof is issued in that case, deliberately — a valid-looking path to a root nobody signed would mislead you. Verify the seal directly and ask the producing party to account for the difference.
Part 3 — Verifying a job seal
A job seal covers a set of records sealed together. It is the only artifact that carries third-party time.
A record ID is enough to get here. The inclusion block returned by /api/verify-chain carries the job_id along with a proof that the record belongs to this seal — see Part 3 below. An earlier version of this guide said that linkage was not public; it is now.
Fetch the seal
curl -s "https://acreseal.com/api/verify-seal?job_id=<job-id>" -o seal.json
jq '{root_matches, signature_valid, trusted_timestamp_present, leaf_count: .seal.leaf_count}' seal.jsonroot_matches and signature_valid are our computations. The remaining steps let you reproduce them yourself, which is the point.
Either may be null, and null is not false. It means we did not compute the value, not that the check failed — verification_status says which: read_failed if an input could not be read, malformed_leaf if a stored leaf is not a 64-character hex digest. When it is read_failed, leaves is null rather than an empty list, because an empty list would be a false answer to "which records does this seal cover". Step 2 onward is unaffected: you can still recompute everything yourself whenever the leaves are present.
Recompute the Merkle root
The response returns leaves — the ordered per-record hashes. Rebuild the root from them using RFC 6962 rules:
- leaf hash =
SHA256(0x00 || leaf_bytes) - internal node =
SHA256(0x01 || left || right) - an odd node at any level is promoted unchanged to the next level
- leaves are the 64-character hex strings decoded to raw bytes, in the order given
Note the odd-node rule: AcreSeal promotes, it does not duplicate the last leaf. A Bitcoin-style implementation will produce a different root on any set with an odd count at some level.
node -e '
const { createHash } = require("crypto");
const sha = (b) => createHash("sha256").update(b).digest();
const leaf = (h) => sha(Buffer.concat([Buffer.from([0x00]), Buffer.from(h, "hex")]));
const node = (l, r) => sha(Buffer.concat([Buffer.from([0x01]), l, r]));
const mth = (ls) => {
if (ls.length === 1) return ls[0];
let k = 1; while (k * 2 < ls.length) k *= 2;
return node(mth(ls.slice(0, k)), mth(ls.slice(k)));
};
const s = JSON.parse(require("fs").readFileSync("seal.json"));
const root = mth(s.leaves.map(leaf)).toString("hex");
console.log(root);
console.log(root === s.seal.merkle_root_hash ? "ROOT MATCHES" : "ROOT DOES NOT MATCH");
'This is the same folder used in Part 4 Step 2, reading seal.json instead of the log entries. The bullets above are the specification; this is its executable form.
Verify the Ed25519 signature
The signed message is four fields joined with pipes — job_id | merkle_root_hash | combined_sha256 | timestamp_sealed_self_asserted — and this builds it and checks it in one step. It prints the message first, so you can see exactly what is being verified:
node -e '
const { verify } = require("crypto");
const s = JSON.parse(require("fs").readFileSync("seal.json"));
const m = [s.job_id, s.seal.merkle_root_hash, s.seal.combined_sha256,
s.seal.timestamp_sealed_self_asserted].join("|");
console.log(m);
console.log(verify(null, Buffer.from(m), s.seal.public_key,
Buffer.from(s.seal.cryptographic_signature, "base64"))
? "SIGNATURE VALID" : "SIGNATURE INVALID");
'Why node and not openssl here. macOS ships LibreSSL, which cannot load an Ed25519 public key at all — it fails with unable to load Public Key before it gets as far as the signature. If you have OpenSSL 3.x (brew install openssl, or most Linux distributions) this is the equivalent. Note jq -j, not -r: -r appends a newline and the signature then fails, the same trap as the checkpoint in Part 4.
jq -j '[.job_id, .seal.merkle_root_hash, .seal.combined_sha256,
.seal.timestamp_sealed_self_asserted] | join("|")' seal.json > seal-msg.txt
jq -r '.seal.cryptographic_signature' seal.json | base64 -d > seal.sig
jq -r '.seal.public_key' seal.json > seal-key.pem
openssl pkeyutl -verify -pubin -inkey seal-key.pem -rawin -in seal-msg.txt -sigfile seal.sigVerify seal.cryptographic_signature (base64) over that message using seal.public_key (SPKI PEM). Cross-check that key against the copy published independently at /.well-known/acreseal-seal-key.pem — if they differ, stop and ask why.
That cross-check is not optional, and here is precisely why. The signature is verified against seal.public_key — the key carried by the seal you are checking. So signature_valid: true tells you the signature matches the key printed beside it. It does not tell you whose key that is. Anyone who can write a seal can sign it with a keypair they generated, and it will report true exactly as this one does. The .well-known file is what closes that loop, because it is published separately from the response you are holding.
As of August 2026 the API does this comparison too and reports it as public_key_is_current_signing_key. Treat it as a convenience, not a substitute — it is still our computation, and the point of fetching the key yourself is to not have to take our word for it. A false there is not proof of forgery. We publish no archive of superseded keys, so a seal signed before a key rotation legitimately carries a key that is no longer the current one, and from the outside it looks identical to a key that was never ours. It means “not the key we sign with today” — a reason to ask, not a finding. null means no signing key is configured and the comparison did not run at all.
Use the four values exactly as the API returns them — do not reformat the timestamp. Ed25519 signs bytes, so …195Z and …195+00:00 are the same instant but different messages, and only one of them was signed. Until August 2026 this endpoint served the second form while signing the first, so anyone following these steps literally got false. The seal-creation endpoint POST /api/v1/jobs/{id}/seal kept doing so for longer, and was corrected separately; every route now publishes the exact string that was signed, and both timestamp_sealed and timestamp_sealed_self_asserted carry it. If you are checking against a response you captured before then, that is the discrepancy.
signature_valid: null means the seal carries no signature at all, which is different from false. Some early seals are unsigned.
Verify the trusted timestamp
This is the step that establishes time independently of AcreSeal. The token is a complete RFC 3161 response, verifiable with OpenSSL alone.
jq -r '.trusted_timestamps[0].token_base64' seal.json | base64 -d > token.tsr
ROOT=$(jq -r '.seal.merkle_root_hash' seal.json)
# Obtain the CA chain for the authority named in trusted_timestamps[n].tsa_url.
# The token embeds the TSA's signing certificate and intermediates (certReq is
# set on every request), so only the ROOT certificate must come from you:
# DigiCert: https://www.digicert.com/kb/digicert-root-certificates.htm
# Sectigo: https://www.sectigo.com/resource-library/sectigo-root-intermediate-certificate-files
curl -sO <root certificate URL from the authority's page> # -> tsa-root.pem
openssl ts -verify -digest "$ROOT" -in token.tsr -CAfile tsa-root.pem
# Read the TSA's stated time:
openssl ts -reply -in token.tsr -text | grep "Time stamp:"Expect Verification: OK. Verifying against the authority's own root — rather than a whole system trust store — proves the token chains to the TSA it claims, not merely to some trusted CA. For a self-signed service such as freetsa.org, its site publishes the CA to use.
A seal may carry more than one token. Seals are anchored to two independent authorities, so trusted_timestamps is an array — iterate it and verify whichever entries you wish. They are independent corroboration, not a quorum: verifying any single token establishes the time on its own. A second authority exists so that one being unreachable when the seal was written leaves the seal anchored rather than unanchored, and so a verifier who distrusts one CA can rely on another. Older seals carry a single token and are no weaker for it.
timestamp_sealed_self_asserted is AcreSeal's own server clock and is not evidence of time. Only the RFC 3161 token constitutes third-party attested time. If trusted_timestamp_present is false, that seal has no independent time attestation and should not be described as though it does.
Part 4 — Auditing the public transparency log
Parts 1–3 verify one record and one seal. This part checks the log they live in. Every job seal's Merkle root is appended to a public append-only log, and checkpoints over that log are signed and independently timestamped — so you can establish that a seal was already in the log at a time a Time Stamp Authority signed for it, and that nothing was removed or reordered since.
Fetch and verify a checkpoint
curl -s "https://acreseal.com/api/transparency/checkpoint" -o checkpoint.json
jq '{tree_size, root_hash, signature_algorithm}' checkpoint.json
# Verify the signature over the EXACT signed bytes.
# jq -j, not -r: -r appends a newline and the signature then fails.
jq -j .body checkpoint.json > checkpoint.txt
jq -r .signature checkpoint.json | base64 -d > checkpoint.sig
curl -s https://acreseal.com/.well-known/acreseal-seal-key.pem -o seal-key.pem
openssl pkeyutl -verify -pubin -inkey seal-key.pem -rawin -in checkpoint.txt -sigfile checkpoint.sigThat needs OpenSSL 3.x. On stock macOS (LibreSSL) it fails with unable to load Public Key, so use this instead — it also sidesteps the newline trap entirely, because no file is written:
node -e '
const { verify } = require("crypto");
const c = JSON.parse(require("fs").readFileSync("checkpoint.json"));
const pem = require("fs").readFileSync("seal-key.pem", "utf8");
console.log(verify(null, Buffer.from(c.body), pem, Buffer.from(c.signature, "base64"))
? "CHECKPOINT SIGNATURE VALID" : "CHECKPOINT SIGNATURE INVALID");
'Expect Signature Verified Successfully. Verify against body as returned — do not rebuild it from tree_size and root_hash. Checking what was actually signed is the point.
Rebuild the root yourself
The log publishes its leaves for the same reason a seal does: without them you would be trusting our arithmetic.
curl -s "https://acreseal.com/api/transparency/entries" -o entries.json
jq '{tree_size, count: (.leaves | length)}' entries.json
node -e '
const { createHash } = require("crypto");
const sha = (b) => createHash("sha256").update(b).digest();
const leaf = (h) => sha(Buffer.concat([Buffer.from([0x00]), Buffer.from(h, "hex")]));
const node = (l, r) => sha(Buffer.concat([Buffer.from([0x01]), l, r]));
const mth = (ls) => {
if (ls.length === 1) return ls[0];
let k = 1; while (k * 2 < ls.length) k *= 2;
return node(mth(ls.slice(0, k)), mth(ls.slice(k)));
};
const { leaves } = JSON.parse(require("fs").readFileSync("entries.json"));
console.log(mth(leaves.map(leaf)).toString("hex"));
'The printed root must equal root_hash from the checkpoint you just verified.
Prove the log only ever appended
This is the check the log exists for, and it needs a checkpoint you saved earlier. Keep the ones you are given.
curl -s "https://acreseal.com/api/transparency/consistency?first=<old size>&second=<new size>"We do not publish a one-command recipe for this step yet. The proof is served by the endpoint above and the verification is RFC 6962 consistency verification, taking both sizes and roots from signed checkpoints — but reducing it to a single shell command we have actually run is work we have not done. Saying so is better than printing a command that does not verify what it claims to. Two properties are worth knowing precisely:
- The proof binds the two roots to each other. It does not bind a size to a root — that binding comes from the checkpoint signature, which is why step 1 comes first.
- A consistency proof does not by itself reveal entries dropped after the prefix it proves. Compare checkpoint sizes: a size that goes backwards is the signal.
Check the checkpoint's trusted timestamp
jq -r '.trusted_timestamps[0].token_base64' checkpoint.json | base64 -d > cp.tsr
DIGEST=$(jq -r '.trusted_timestamps[0].digest_hex' checkpoint.json)
# Same CA-chain rule as Part 3: obtain the issuing authority's ROOT certificate
# (the token embeds the signing certificate and intermediates).
openssl ts -verify -digest "$DIGEST" -in cp.tsr -CAfile tsa-root.pemThe digest is SHA-256 of the whole checkpoint body, so the token binds the size and the root together — a token over a bare root would be silent about how much log it covered. Checkpoints are anchored to two independent authorities; either one establishes the time on its own.
Prove YOUR record reaches the anchored head
When the log head has been committed to an external append-only log (Sigstore Rekor), coverage of your record is checkable: fetch the anchor, then an inclusion proof at the anchored size, and fold it yourself with the exact recipe from Step 2.
curl -s https://acreseal.com/api/transparency/anchors > anchors.json
HEAD_POS=$(jq -r '.latestAnchor.head_position' anchors.json)
HEAD_DIGEST=$(jq -r '.latestAnchor.head_digest' anchors.json)
SEAL_ROOT=$(jq -r '.seal.merkle_root_hash' seal.json)
curl -s "https://acreseal.com/api/transparency/proof?leaf=$SEAL_ROOT&tree_size=$HEAD_POS" > logproof.json
# Fold: leaf = SHA256(0x00 || seal_root_bytes), each sibling as
# SHA256(0x01 || left || right) per the proof's position tags,
# then compare your result to $HEAD_DIGEST — the anchored head.If latestAnchor is null, no anchor exists yet and every sealed record is pending_anchor. If the proof answers included_at_size: false with beyond_tree_size, your record entered the log after the anchored head — a timing state, not a problem. A fold that does not reach the anchored head is worth reporting: the verify bundle treats that as an integrity alarm and refuses to issue.
Look the anchor up on the provider, publicly
The anchor row's provider_reference is the Rekor log index, and its provider_receipt is the provider's response verbatim — but you need neither on trust: look the entry up on the public instance yourself.
REF=$(jq -r '.latestAnchor.provider_reference' anchors.json)
curl -s "https://rekor.sigstore.dev/api/v1/log/entries?logIndex=$REF" > rekor.json
# Rebuild what was committed: the checkpoint BODY (published verbatim —
# never re-render it), its SHA-256 digest, then the SHA-512 OF THE HEX
# STRING (Rekor requires SHA-512 imprints for Ed25519 entries):
jq -rj '.body' checkpoint.json > cp-body.txt
CP_DIGEST=$(shasum -a 256 cp-body.txt | cut -d' ' -f1)
printf %s "$CP_DIGEST" | shasum -a 512 | cut -d' ' -f1
# Compare that to the Rekor entry's spec.data.hash.value:
jq -r 'to_entries[0].value.body' rekor.json | base64 -d | jq -r '.spec.data.hash.value'The committed artifact bytes are the 64-character ASCII hex string of the checkpoint digest — so the provider's data.hash.value is SHA-512 of that string (Rekor's requirement for Ed25519 entries), and the digest itself is SHA-256 of the body, which binds the origin, tree size and root together. The entry's signature is ed25519ph (RFC 8032 pre-hashed) by the same key published at /.well-known/acreseal-seal-key.pem — sigstore tooling verifies it against that key (plain openssl pkeyutl cannot; it speaks only pure Ed25519). The entry's own inclusion proof is Rekor's tree, checkable with Rekor tooling.
What this log does not prove. It is signed and served by AcreSeal. The timestamps prevent backdating — claiming an entry was logged earlier than it was would require forging a TSA signature. They This log is signed and served by AcreSeal. Consistency proofs bind states YOU have observed; without an independent witness cosigning checkpoints, a split view is not excluded. Retain the checkpoints you are shown.
What is actually in this log. Inclusion establishes that a seal root was logged. It does not establish that the records under that seal are operational field evidence, and for most leaves in this log they are not. 8 of 9 leaves (index 0, 1, 2, 3, 5, 6, 7 and 8) are seals over DEMONSTRATION records — test and fixture content, not operational field evidence. They were logged before demonstration data was refused at sealing, and they are permanent: entries are never deleted, reordered or rewritten. 1 (index 4) is a seal over records established as non-demonstration data. Where a seal signature is present and verifies, that proves those seal bytes were signed under AcreSeal's key — not that the records beneath them are operational evidence. This is AcreSeal's own statement about its own log: the flag it derives from is not a public column, so you cannot recompute this summary from the log alone. You can check any single seal against it at /api/verify-seal?job_id=<job-id>, which needs no account and reports the same flag per job.
Seals enter the log on a schedule rather than instantly. The maximum merge delay is one hour, so a very recently sealed job may not yet appear.
Machine-readable Verify Bundle
Every procedure above works, and every one of them requires you to already know which digest to compare, which timestamp form was signed, and which endpoint answers which question. The Verify Bundle publishes all of that as one versioned document — the digests, the RFC 6962 inclusion proof, the exact Ed25519-signed message, any RFC 3161 token, and a structured program from which the commands below are generated rather than written.
# The document
curl -sS 'https://acreseal.com/api/verify-bundle?tracking=<12-hex-code>' | jq .
# The same program as a shell script
curl -sS 'https://acreseal.com/api/verify-bundle?tracking=<code>&format=cli' > verify.sh
# The reference verifier, with stable exit codes
node scripts/verify-bundle.mjs --tracking <code> --base https://acreseal.comFour states, and none of them collapse into another
- sealed
- — an inclusion proof reaches the sealed Merkle root AND the signature verifies over the exact message the document publishes.
- not_sealed
- — a finding: no complete, signed job seal covers this record.
- not_found
- — a lookup completed and found nothing. A confirmed absence.
- integrity_unavailable
- — a statement about the check, not about the record. A read did not complete, so nothing was determined. It is not evidence that the record is unsealed, and not evidence that it is sealed. Served with HTTP 503.
The HTML never claims more than the JSON. Every verdict word on /verify/bundle is looked up from the document's own sealStatus, and every command shown there is generated from the same program the API returns. If the page and the document ever disagreed, the page would be the one that is wrong — so fetch the JSON and compare.
Exit codes from the reference verifier are stable: 0 sealed and every check passed, 1 not sealed and every check that ran passed, 2 no conclusion was verified — a failed check, a network failure, or either of the two states above that establish nothing — and 3 a usage error. Four codes cannot carry four protocol states plus a failure, so the verifier also prints a STATUS line; read that when you need the distinction. Schema: /.well-known/acreseal-verify-bundle.schema.json.
The bundle publishes strictly less than /api/verify-chain: no raw environmental hash inputs, no EXIF GPS pair, no device make or model, and no capture-time photo digest. None is needed to reproduce the integrity conclusion, and the capture-time digest in particular reproduces from no download, so offering it would send you down a failing path.
Verification Receipts
A receipt proves a verification event ran; it is not the evidence seal.
It is also not the submission receipt — the PDF a reporter downloads after filing. Three different documents, three different jobs: the seal is the evidence, the submission receipt is the reporter's copy of what they filed, and a Verification Receipt is the record that someone checked the seal.
Everything above this section works without an account and always will. Verification Receipts are an addition, not a replacement: they let a party who checked a record show, later, that they checked it — and they are themselves hash-chained, Ed25519-signed, and reproducible with the same two commands you have already used.
Running a verification requires an API key. Reading the resulting receipt for a landowner report does not — the signed bytes carry no identity, so the document a customer receives is byte-for-byte the document you can fetch:
# 1. Fetch the receipt (no key, no account) curl -s https://acreseal.com/api/v1/receipts/<receipt-id> -o receipt.json # 2. The canonical bytes that were signed jq -r .receipt_body receipt.json > body.txt # 3. Their SHA-256 must equal receipt_hash shasum -a 256 body.txt jq -r .receipt_hash receipt.json # 4. Verify the signature over the domain-separated hash curl -s https://acreseal.com/.well-known/acreseal-seal-key.pem -o key.pem printf 'acreseal.receipt.v1|%s' "$(jq -r .receipt_hash receipt.json)" > signed.txt jq -r .signature receipt.json | base64 -d > sig.bin openssl pkeyutl -verify -pubin -inkey key.pem -rawin -in signed.txt -sigfile sig.bin
The acreseal.receipt.v1| prefix is part of what was signed. The same key signs job seals over a different message, and without that separation “this signature verifies” would stop meaning “this is a Verification Receipt”.
What a Verification Receipt does not tell you. It does not say the record it examined is sealed — the receipt carries its own sealStatus for that, on a separate axis. It does not say the verifier acted on the result. And it does not say who ran the verification: the signature deliberately covers no identity, so a valid receipt proves that someone verified this record at that time. That it was a particular company is AcreSeal's record, not a cryptographic fact, and you should treat it exactly as you would any other vendor's log.
Receipts over internal job seals are not public and return 404. If you hold receipts that matter to you, keep the bodies and keep a copy of the public key: a chain that no longer walks, or a published key that stops matching what you already hold, is how you would notice a change on our side.
What this does and does not prove
| Established | Not established |
|---|---|
| The stored photo is byte-for-byte unchanged since sealing. | That the photograph depicts what its description claims. No cryptography can establish that. |
| The capture context — time, coordinates, weather — is bound into a digest that has not changed. | That the device reported its position honestly. GPS can be spoofed at the device. |
| The audit history is append-only and unbroken, enforced by database triggers. | That a record was created when its own timestamp says, unless a job seal with an RFC 3161 token covers it. |
| For a sealed job: that the root existed at a time an independent authority signed for. | Anything about records not included in that seal's leaves. |
| That a seal root was in the public log at a time an independent Time Stamp Authority signed for — so it cannot have been created later and backdated. | That the log shown to you is the same log shown to everyone else. Detecting a split view needs an independent witness, which does not yet exist. |
| That a leaf hash was appended to the log and covered by a signed checkpoint. | That a leaf is operational evidence. 8 of 9 leaves seal demonstration records; inclusion establishes that a seal root was logged, not what the seal covers. |
| That a specific record is one of the leaves under that root — via its inclusion proof, checkable without trusting us. | That the sealed set was complete. A seal proves what it covers, never that nothing else existed or was withheld. |
Per-record integrity is proven by a linear SHA-256 hash chain, not a Merkle tree; no per-record Merkle inclusion proof is asserted. Job-level seals do use an RFC 6962 Merkle tree, verifiable separately as described above.
Frequently asked
Do I need an account, an API key, or AcreSeal's permission?
No. Every endpoint and file referenced on this page is public and unauthenticated. The verification steps use shasum, exiftool and openssl — standard utilities, none of them ours. That is not the same as the hosted record being independent of us: every procedure below starts by fetching from acreseal.com. Whatever you have already downloaded — the photo, the record JSON, the seal JSON with its RFC 3161 token, and the public key — you can keep checking with those same tools. Downloading and keeping them is a step you have to take.
shasum gives a different value than the hash I was shown. Is the evidence bad?
Check which hash you are comparing against. A record publishes up to two digests: the stored-bytes hash (server_photo_hash), which a downloaded file reproduces, and a capture-time hash computed in the browser before AcreSeal embedded its provenance EXIF, which it will not. Compare against the stored-bytes value. Records created before server-side re-hashing publish only the capture-time hash, and for those no downloaded file will reproduce it — the record says so explicitly rather than implying otherwise.
Why does the record's timestamp not prove when the photo was taken?
Because AcreSeal asserted it. A record's timestamps come from the submitting device and our servers, which is evidence of our records but not independent proof of time. Third-party time exists only at the job-seal level, via an RFC 3161 token issued by a Time Stamp Authority we do not control. If a seal reports trusted_timestamp_present: false, it has no third-party time token and should not be described as though it does.
I have a record ID. How do I get to its job seal?
The inclusion block in /api/verify-chain gives you both the job_id and a Merkle inclusion proof placing that record inside the seal. Earlier versions of this guide said the linkage was not public; that was true then and is no longer. The proof matters more than the id: it lets you show the record is in the sealed set rather than taking our word that it is. If status is not_sealed, the record has not been sealed into a job and no third-party timestamp covers it. If status is root_mismatch, the job's report set has changed since sealing and no proof is issued — verify the seal directly and ask the producing party to explain the discrepancy. If status is unverified, no determination was made at all: an input could not be read on our side. Treat it as an absent answer rather than a negative one, and retry — it is not evidence that the record is unsealed.
signature_valid came back null. Is that a failure?
No — null means the seal was written without an Ed25519 signature, so there is nothing to verify. It is distinct from false, which would mean a signature exists and does not check out. Some early seals carry no signature. Treat null as an absent attestation, not a failed one.
The EXIF says the software is AcreSeal. Where is the camera's metadata?
AcreSeal writes a provenance block into the stored file, and in doing so sets Software, ImageDescription, UserComment and GPS from the capture context. Original camera EXIF is carried forward where present, but two common cases leave none to carry: photos captured through a mobile browser's camera are stripped of metadata by the operating system before the page receives them, and files above 2 MB are re-encoded during compression. EXIF is corroborating evidence here, never the integrity anchor.
If any step on this page fails against a record you were given, that is a finding worth raising with whoever produced the evidence — not a quirk to work around.