API integration
The goal: nobody opens a console to record each step. An official authorizes your software once; the institution’s own system then records every checkpoint — a case opened, an inspection, an approval, a payment — and hands the citizen a Receipt. Writes need no API key: an event is accepted only if it extends its issuer’s HardState Log and is signed by a key that log authorizes.
Everything below uses the public API at https://api.hardstate.net (http://localhost:8787 when running locally).
1. Choose who signs
Section titled “1. Choose who signs”| Signer | When to use | How it is authorized | Attributed to |
|---|---|---|---|
Session of a person (role: session) |
A workstation, tablet, or field app during someone’s shift | The person approves it once with their passkey (session.open); it expires (≤ 30 days) |
That person, by name |
System key (role: system) |
A process with no person behind it: a payment gateway, an appointment system, a scanner | An admin registers it once (key.add), scoped to some step types |
The system, by name |
| Passkey, step by step | Low volume, or high-stakes acts signed in person | Already registered in the institution’s log | That person |
Prefer sessions for anything a person is responsible for — accountability stays personal, and a lost device is closed
in one click. Use system keys only for steps no person performs, and always scope them ("types": ["fee.paid"]). See
Officials & sessions for how these are created.
2. Event types are yours
Section titled “2. Event types are yours”Each step is an event of a type you choose, written domain.action in lowercase: case.open, case.inspect,
permit.approve, permit.deny, fee.paid, diploma.issue, aid.delivered, contract.award,
land_title.transfer. Only issuer.*, key.*, and session.* are reserved by the protocol. Keep one type per kind
of act, put the act’s public facts in public, and use case for the file or procedure number — so a citizen’s
whole procedure reads as one timeline.
3. Authorize your software (once)
Section titled “3. Authorize your software (once)”Your software generates its own Ed25519 key pair and keeps the private key — a file with 0600 permissions, or
better an HSM or a cloud KMS. It then asks to be authorized with a request link:
https://app.hardstate.net/console?device=<base64url(JSON)>
JSON = { "v": 1, "label": "Permits system — City Hall", "publicKey": "<SPKI DER, base64url>", "types": ["case.*", "permit.*"] }Open the link while signed in to the console (Devices & sessions):
- an official approves it as their session, for a chosen time; or
- an admin registers it as a system key, valid until revoked.
The key id your software uses as actor is:
actor = "ed25519:" + hex(SHA-256(spki))[0:32]4. Record each checkpoint: prepare → sign → submit
Section titled “4. Record each checkpoint: prepare → sign → submit”Any language that can make an HTTP request and an Ed25519 signature can integrate. No canonical-JSON library is needed — the API prepares the exact event for you.
-
Prepare.
POST /v1/events/preparewith the step:{ issuer, type, case, public, docs, actor }. It answers{ event, canonical, hash, challenge }, whereeventsits on the current head of your log. -
Check, then sign. Compare
eventagainst what you sent, then sign the 32 bytes ofhashwith your Ed25519 key. Signing only what you meant is the point of this step. -
Submit.
POST /v1/eventswith{ event, sig: { kind: "ed25519", signature: <base64url> } }. It answers201with{ hash, seq, receipt, proof }. On409another step got there first — prepare again on the returned head.
# 1. Preparecurl -s https://api.hardstate.net/v1/events/prepare -H 'content-type: application/json' -d '{ "issuer": "city-of-esperanza", "type": "permit.approve", "case": "BP-2026-0042", "public": { "decision": "approved", "validUntil": "2027-10-20" }, "actor": "ed25519:4be1…"}'
# 2. Sign the returned .hash (32 bytes) with your Ed25519 key, then:
# 3. Submitcurl -s https://api.hardstate.net/v1/events -H 'content-type: application/json' \ -d '{ "event": { … the event returned by prepare … }, "sig": { "kind": "ed25519", "signature": "…" } }'import base64, hashlib, json, urllib.requestfrom cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKeyfrom cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
API = "https://api.hardstate.net"b64url = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def post(path, body): req = urllib.request.Request(API + path, json.dumps(body).encode(), {"content-type": "application/json"}) with urllib.request.urlopen(req) as r: return json.load(r)
key = Ed25519PrivateKey.generate() # load yours from a protected file, HSM, or KMSspki = key.public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)actor = "ed25519:" + hashlib.sha256(spki).hexdigest()[:32]
def record(issuer, type_, case, public, docs=()): p = post("/v1/events/prepare", {"issuer": issuer, "type": type_, "case": case, "public": public, "docs": list(docs), "actor": actor}) assert p["event"]["type"] == type_ and p["event"]["public"] == public # sign only what you meant sig = key.sign(bytes.fromhex(p["hash"])) return post("/v1/events", {"event": p["event"], "sig": {"kind": "ed25519", "signature": b64url(sig)}})
r = record("city-of-esperanza", "permit.approve", "BP-2026-0042", {"decision": "approved"})print(r["receipt"]) # put this link (and its QR) in the notification you already send the citizenimport { HardStateClient, signEd25519 } from "@hardstate/core";
const client = new HardStateClient("https://api.hardstate.net");const r = await client.append( { issuer: "city-of-esperanza", type: "permit.approve", case: "BP-2026-0042", actor, public: { decision: "approved" } }, (event) => signEd25519(event, privateKey), // CryptoKey (Ed25519));console.log(r.receipt);The SDK builds the canonical event itself, so it never relies on the server’s prepare, and it retries on 409.
5. Documents stay private
Section titled “5. Documents stay private”Never send documents to HardState unless the institution decides to publish them. Commit to each file instead:
digest = SHA-256(file)salt = 32 random bytes (keep it: it goes in the citizen's receipt)commitment = SHA-256("hardstate/doc/v1" ‖ salt ‖ digest)docs = [{ "label": "Inspection report", "commitment": hex(commitment), "mime": "application/pdf" }]Give the citizen the receipt link with the salts in its fragment:
<receipt>#k=<commitment[0:16]>.<salt hex>,…. The fragment never reaches any server; with it, anyone holding the
file can check it is the recorded one. To publish a document on purpose, send publish: [{ commitment, digest, salt }]
with the event, then PUT /v1/docs/<commitment> with the file bytes.
6. Receipts for citizens
Section titled “6. Receipts for citizens”POST /v1/events returns a receipt link (https://app.hardstate.net/r/<hash>). Append the #k=… fragment when the
step has documents, and put the link and its QR into what the citizen already receives — email, SMS, the printed form.
Every step of the case links to the full timeline, and anyone can verify it against Bitcoin.
7. Operating it
Section titled “7. Operating it”- Custody. System and session private keys never leave the machine that uses them (
0600, HSM, or KMS). - Scope. Give each key the narrowest set of
typesthat works. - Retries.
409means the log head moved — prepare again. Network errors are safe to retry with the same signed event: a duplicate is refused, not recorded twice. - Pace. Records reach Bitcoin within 1–3 hours through OpenTimestamps (free) and in the daily HardState Anchor. Express anchoring (~10–20 min) is available on institutional plans.