Cloud Proof over HTTP
This page is the contract between your code and the recording door of a Cloud Proof environment,
in plain HTTP. The official SDK, the Python package
tallwright-cloud, implements this contract, and it
is the way to record from Python. A test holds the two to each other, and runs the shell block below
against a real door.
This page is for a language without an SDK. Code that follows it does what the SDK does — commits, keeps the disclosure before it sends, sends the same bytes again — and its author answers for doing it exactly as written here. It is not a way to build a second recorder beside the SDK.
What the resulting evidence proves, and what it does not, is exhibit-format.md.
What Tallwright sees, and what it never does
Cloud Proof holds no content. Your content and the salt that commits to it stay on your machine.
The door receives an address computed from the two. It refuses the fields that carry content
(payload, payload_b64); what you put in the fields that travel in the clear is sealed as it is.
| The door receives | It never receives |
|---|---|
payload_hash: the address of salt ‖ content |
the content |
record_id: your name for the event |
the salt |
action_type, actor_authority, timestamp, when you send them |
the disclosure that opens the address |
| your environment key, to know which log the line belongs to |
The address alone cannot be turned back into the content: the salt is 32 random bytes, and nobody but you holds it. The record does not say what was recorded, only that whatever it was has not changed since. To show what it says, you disclose the content and the salt later, to whoever needs to check them.
record_id, action_type, actor_authority and timestamp travel in the clear and are sealed
into the record and into every exhibit of it. Name events, kinds and rules in them, never people.
call-8842-1 names the event; a case number with a customer's name in it is a disclosure nobody
can take back.
1. Commit, on your machine
For every record:
- Draw a salt: exactly 32 bytes from the operating system's random source, new for every record. Never reuse one, and never derive one.
- Compute the address:
"f1220"followed by the lower-case hex SHA-256 of the salt followed by the content.12 20is the multihash prefix for SHA-256 (exhibit format §1). - Keep the disclosure: the JSON object
{"salt": …, "payload_b64": …}, both values standard base64 of the raw bytes (exhibit format §6). The verifier reads this object as it is. Without it the record can never be opened again, by anyone.
2. Send the line
POST <your ingest address>/v1/records
Authorization: Bearer <your environment key>
Content-Type: application/x-ndjson
Content-Length: <required: a streamed body is refused with 501>
{"action_type":"refund.approved","payload_hash":"f1220…","record_id":"call-8842-1"}
One JSON object per line, up to 4 MiB a batch. A batch is all or nothing: one unusable line records none of them, and the answer names the line.
| Field | |
|---|---|
record_id |
required. Your name for this event, unique in this log. It is how a retry is told apart from a new event |
payload_hash |
required. The address from step 1 |
action_type |
optional. Present when the agent DID something; absent when it received something. That is what decides the record's event type |
actor_authority |
optional. On whose authority, or under which rule |
timestamp |
optional. RFC 3339 with its offset (2026-10-08T09:30:12+02:00), your clock. Leave it out and the door stamps the moment it first takes the record. Either way it is a stated time, not an independent one: the time a verifier checks is the timestamp over the log head, in the exhibit |
Any other field is refused by name. So are payload, payload_b64 and subject_id: this door holds
no content, and taking content would put your salt in our hands.
The whole of steps 1 and 2 in a shell, with GNU coreutils and curl, once per record with a
RECORD_ID of its own. On macOS, shasum -a 256 stands in for sha256sum, and base64 < for
base64 -w0 <:
# One folder per record, readable by you alone. mkdir stops at a name already used, so no salt is
# ever overwritten.
umask 077
mkdir "$RECORD_ID" && cd "$RECORD_ID"
# Your content stays on this machine.
printf '%s' "$CONTENT" > content.bin
head -c 32 /dev/urandom > salt.bin
PAYLOAD_HASH="f1220$(cat salt.bin content.bin | sha256sum | cut -c1-64)"
# The pair that opens the address. Keep it: without it the record can never be opened.
printf '{"salt":"%s","payload_b64":"%s"}\n' "$(base64 -w0 < salt.bin)" "$(base64 -w0 < content.bin)" > disclosure.json
rm salt.bin content.bin
# The line: the keys in this order are the RFC 8785 form, byte for byte what the Python package sends.
printf '{"action_type":"refund.approved","payload_hash":"%s","record_id":"%s"}\n' "$PAYLOAD_HASH" "$RECORD_ID" > line.json
curl --silent --show-error --fail-with-body \
-H "Authorization: Bearer $TALLWRIGHT_KEY" \
-H "Content-Type: application/x-ndjson" \
--data-binary @line.json \
"$TALLWRIGHT_INGEST/v1/records" > answer.json
--data-binary @file sends a Content-Length, which this door requires. The folder now holds the
disclosure, the line and the answer, together, and is the only copy of the pair.
3. The answer
{
"result": "recorded 1 record(s); log is now 42",
"code": 0,
"receipts": [
{
"schema": "tallwright-receipt/1",
"record_id": "call-8842-1",
"status": "recorded",
"leaf_index": 41,
"record_hash": "f1220…",
"prev_record_hash": "f1220…",
"timestamp": "2026-10-08T09:30:12.345678+00:00",
"timestamp_source": "archive",
"log_size": 42,
"note": "a confirmation, not a proof: unsigned, and no witness has seen this record until an anchor covers its leaf_index"
}
]
}
One receipt per line sent, in the order sent:
| Field | |
|---|---|
status |
recorded now, or already_held: an earlier send of the same line was recorded |
leaf_index |
the record's position in the log, from 0. The exhibit of this record names the same leaf |
record_hash |
the content address of the record the door built around your line. It covers your fields and the deployment's own (which agent, whose deployment, which retention class) |
prev_record_hash |
the record before it in the chain, or null for the first |
timestamp, timestamp_source |
the record's time, and who set it: sender (you), archive (the door) or unknown (a retry of a record from an earlier batch) |
log_size |
how many records the log held when the receipt was written |
A receipt is a confirmation, not a proof, in the door's own words. It is unsigned. No witness
has seen the record until an anchor covers its leaf, which happens at the next anchor run (the
first record of a new environment asks for one at once). The proof is the exhibit made after that:
the record, its inclusion proof, the signed checkpoint and the witness's countersignature over it,
checked offline together with your disclosure. A code other than 0 comes with a warning: the
records ARE held, and something on the operator's side needs repair. Do not send them again under
new names.
4. When it is refused, and when to send again
| Status | Recorded? | What to do |
|---|---|---|
| 200 | yes | keep the receipt beside the disclosure |
| 400 | no | line N is not a usable record: the line is unusable, and the answer says why. The same line will never be accepted. Any other 400 is about the batch or the door: send the same bytes again |
| 409 | no | the log holds a DIFFERENT record under this record_id. On a first send that is another event: send yours under its own name. On a resend, whether that record is an earlier send of this same line cannot be told from the answer: an exhibit of the record shows which |
| 413 | no | the batch is over 4 MiB. Split it |
| 429 | no | this environment sent more batches at once, or in a minute, than the door takes from one environment, and the answer says how many. Send the same bytes again after the seconds in Retry-After |
| 501 | no | the body was streamed. Send a Content-Length |
| 401 | no | the key is not one the door holds |
| 403 | no | the account behind the key is closed; the answer says since when |
| 404 | no | not this door's path: the address ends in /v1/records |
| 507 | no | this Environment's log needs repair on the operator's side: a write before yours was cut short, by a full disk for example. Sending again does not help until it is repaired. Keep the batch, and send the same bytes once it is |
| 500, 502, 503, 504, a lost answer | not known | send the same bytes again |
Sending again is safe, and it must be the same line. A record the log already holds is recognised
by its record_id and the address, answered already_held, and never written twice. A line with a
new salt is a different address under the same name, which is a 409. So write the disclosure and the
line down BEFORE you send, and resend from what you wrote down. The Python package does exactly this:
the pair and the line are on disk before the door is asked, and a record with no receipt stays
pending until it is confirmed.
A refusal that does not carry the door's own JSON sentence came from something in between, such as a proxy's error page, and says nothing about the line: send the same bytes again later. Do not follow a redirect with your key: it would carry the key to an address you did not configure.
5. The same contract from Python
from tallwright_cloud import Tallwright
client = Tallwright(TALLWRIGHT_INGEST, TALLWRIGHT_KEY, "./tallwright-evidence")
captured = client.capture(content, action_type="refund.approved")
# ./tallwright-evidence/<record_id>/disclosure.json, line.json and receipt.json
The package adds two things the plain HTTP path leaves to you. Without a record_id of your own,
it names records <session>.<number>, counting up from 1 for each client, so a capture that never
arrived shows as a hole in the names the log holds (gaps). A hole is a capture that was numbered
and is not in the log. No sequence can show a missing tail, a session that sent nothing, or an
action nothing captured. And it keeps a record whose answer never came as pending, so
client.resend(record_id) can send it again from a later process. Do not wrap capture in a retry
loop of your own: after NotConfirmed, resend; a new capture is a new record with a new salt, and an
answer that was only lost becomes the same event recorded twice. A record refused with 401 stays
pending too, under the key it was written with: a client never sends another key's pending records on
its own, so two environments whose evidence was pointed at one directory by mistake do not send each
other's. Once the key is right, resend(record_id, adopt=True) sends it with the client's key, which
is your statement that the new key belongs to the same environment.
In one place the package does less than the table in section 4: it does not read Retry-After.
capture sends a 429 again after half a second, a second and two seconds, and then raises
NotConfirmed with the record pending; in the background a 429 waits the same growing pause as any other
answer that decides nothing.
client.submit(content, …) does the same work and returns once the evidence is safe, sending in the
background, several lines to a request; client.flush() waits for it. Evidence that must outlive a
container goes in a bucket (S3Evidence), and many end customers each get their own client, key and
evidence from Environments. The package's README has the whole of it.
The package keeps the content, the salt and the key out of its own messages, logs and reprs, and out of the local variables of its own frames, so an error tracker that records local variables records none of them from this package. Your own code's frames are yours. An exhibit link is a credential too: keep it out of logs and traces. On Windows the evidence directory takes the permissions of the folder it is made in, so make it where only the agent's account can read.
6. Your first proof, end to end
What you need: the ingest address and the key of an environment, Python 3.11, 3.12 or 3.13 with
tallwright-cloud, and the verification program evidence-verify (the Verifier screen of the
backoffice says how to get it). Those three are the Pythons the verification program carries wheels for,
so it installs without an index on them, and not yet on a newer one.
Record, and keep the pair.
import os
from tallwright_cloud import Tallwright
client = Tallwright(os.environ["TALLWRIGHT_INGEST"], os.environ["TALLWRIGHT_KEY"], "./tallwright-evidence")
captured = client.capture("Refund of 120 EUR approved for ticket 8812", action_type="refund.approved")
print(captured.record_id, captured.receipt.status, captured.receipt.leaf_index)
./tallwright-evidence/<record_id>/ now holds disclosure.json (your content and its salt),
line.json (what was sent) and receipt.json (what the door answered). This folder is the only
place your content exists for Cloud Proof. Keep it the way you keep the records it describes, and
never send it to us.
Get the exhibit. Ask the backoffice for an exhibit of this record, on its Exhibits screen. It gives a link that opens once, within fifteen minutes, without an account. Then:
client.save_exhibit(link) # files evidence.json, policy.json and our-claims.json under <record_id>/exhibit/
ExhibitNotReady (409) means the door could not build the exhibit now, and its sentence says why:
most often that no witness has covered the record yet. The link was not used and still opens until it
expires: try again after the next anchor run. The first record of a new environment asks for one at
once. LinkSpent (410) means the link was used; ask for a new one. An exhibit of another record, or
one whose record says anything the line did not (another action, authority or stated time, another
address, a capture tier nobody claimed), is refused and nothing is filed.
Or without a click. An environment can also be given an export key, on the Environments
screen, shown once. It takes the exhibit of one record of that environment at a time from the exhibit
address, and does nothing else: it records nothing, and the recording key takes no exhibit. Keep it
where the exhibits are wanted rather than where the agent runs. With RECORD_ID the name the
quickstart printed:
import os
from tallwright_cloud import Exhibits
exhibits = Exhibits(os.environ["TALLWRIGHT_EXHIBITS"], os.environ["TALLWRIGHT_EXPORT_KEY"], "./tallwright-evidence")
print(exhibits.save(os.environ["RECORD_ID"]))
It files the same three files in the same place, checked the same way, and nothing is spent by asking:
ExhibitNotReady (409) says the record is not witnessed yet, so ask again after the next anchor run;
ExportKeyRefused (401) says the key was never issued, was replaced or was withdrawn; any other refusal
comes with the door's sentence. Over plain HTTP, in the record's folder from section 2:
RECORD=$(printf '%s' "$RECORD_ID" | base64 -w0 | tr '+/' '-_' | tr -d '=')
curl --silent --show-error --fail-with-body \
-H "Authorization: Bearer $TALLWRIGHT_EXPORT_KEY" \
"$TALLWRIGHT_EXHIBITS/v1/records/$RECORD/exhibit" -o exhibit.zip
unzip -o -j -d exhibit exhibit.zip
A refusal is kept too: --fail-with-body writes the door's answer to exhibit.zip, and curl says only
error: 409. A 409 here is the same "not witnessed yet" as above, and exhibit.zip then holds the door's
sentence instead of a zip: ask again after the next anchor run. unzip is a package of its own on minimal
Linux installations; where it is missing, the Python Exhibits above files the same three files.
Check it yourself, offline. In the record's folder:
evidence-verify exhibit/evidence.json --policy exhibit/policy.json --disclosure disclosure.json
python -c "import json; p = json.load(open('exhibit/policy.json')); c = json.load(open('exhibit/our-claims.json')); p['as_of'], p['tsa_anchor'] = c['as_of']['value'], c['tsa_anchor']['value']; json.dump(p, open('accepted.json', 'w'))"
evidence-verify exhibit/evidence.json --policy accepted.json --disclosure disclosure.json
python -c "import base64, json; d = json.load(open('disclosure.json')); p = bytearray(base64.b64decode(d['payload_b64'])); p[0] ^= 1; d['payload_b64'] = base64.b64encode(p).decode(); json.dump(d, open('changed.json', 'w'))"
evidence-verify exhibit/evidence.json --policy accepted.json --disclosure changed.json
- Exit 3,
bundle: NOT VERIFIED,integrity: VERIFIED. The record is under the signed root, the log's key signed it, the witness's key countersigned it, anddisclosed_payloadpasses: your content and salt produce the address this record commits to. What is missing is a verdict on time. The reference time and the timestamp authority are the reader's to choose;policy.jsonis our proposal and does not choose them for you. - Exit 0,
bundle: VALID, once the two valuesour-claims.jsonasks the reader to accept are accepted. Read what that rests on: here the log, its keys, the witness and both values all come from us. On Cloud, Tallwright runs the door and the witness both, and this exhibit claims no separation between them. A reader who does not take our word substitutes their own policy and register. - Exit 1,
bundle: INVALID. The fourth line changes one bit of the first byte of your content, whatever the content says, and one changed byte of the original is a disclosure the sealed record contradicts:FAIL disclosed_payload. So does a different salt. A salt that is not 32 bytes is not a contradiction but an unreadable disclosure, and answers 3. A copy you change some other way proves nothing unless it differs from the original: a search-and-replace that finds nothing leaves the disclosure as it was, and that answers 0.
A test runs these five lines against an exhibit of a real record, witnessed over TLS, and holds the three answers above to what the verifier says.
What this path does not prove
Every record here is a statement your system made: "this happened, and this is what it said". The seal proves that statement has not changed since it reached the door, at that position, under a log head the witness countersigned. On Cloud, Tallwright runs the door and the witness both, and nothing here claims they are independent of each other. It does not prove the statement is true. It does not prove your system recorded every action it took: a call made through some other path leaves no record to be missing. Integrity of what was captured, not completeness of what happened.