TallwrightDocs

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:

  1. Draw a salt: exactly 32 bytes from the operating system's random source, new for every record. Never reuse one, and never derive one.
  2. Compute the address: "f1220" followed by the lower-case hex SHA-256 of the salt followed by the content. 12 20 is the multihash prefix for SHA-256 (exhibit format §1).
  3. 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
  1. 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, and disclosed_payload passes: 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.json is our proposal and does not choose them for you.
  2. Exit 0, bundle: VALID, once the two values our-claims.json asks 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.
  3. 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.