Files / Direct API — anchor a file or report

The straight-line path. You have a file (a report, an export, a contract, a JSON event, a CSV) and you want a Bitcoin-anchored proof that you held those exact bytes at a specific time. This guide walks the bearer-auth JSON API end-to-end: create a folder, hash locally, anchor the hash, store the fields you need for later verification. Roughly ten minutes from a blank shell to a verified .mbnt bundle.

Companion docs: API reference · OpenAPI spec · What to hash · Production checklist · Compatibility map

Naming. The canonical vocabulary is proof / folder; responses emit canonical field names only, and requests written against legacy spellings keep working forever. Full alias map: Compatibility map.

1. The 60-second framing

You hash the file on your machine. The file itself never leaves you. Satsignal anchors a 20-byte commitment to that hash in an OP_RETURN output of one BSV transaction. You get back a proof_id, a txid, and a .mbnt bundle that lets anyone (no account, no API key) confirm later that the file existed in bit-identical form by the block time. Five seconds with a coffee in your other hand — the only moving pieces are an HTTPS POST and a SHA-256.

Concrete: you generate an eval report at 14:30 UTC, hash it, hit /api/v1/anchors. By 14:31 UTC there is a public BSV transaction whose payload commits to your report's hash. A counterparty six months later can verify the original file matches that exact transaction, with no need to ping us.

2. Use this when

3. What you send

The wire endpoint is POST /api/v1/anchors on https://app.satsignal.cloud. Bearer auth (Authorization: Bearer sk_...). JSON body. Mode (standard / sealed / manifest) is inferred from body shape — manifest indicates a multi-item batch (an evidence shape), not a privacy mode. For a plain file anchor it's standard mode, the default.

fieldtyperequiredmeaning
folder_slugstringyesthe folder this proof files under. Created once with POST /api/v1/folders.
sha256_hexstringyes64 lowercase hex chars — sha256(file_bytes).
file_sizeintegeryesbyte length of the source file.
categorystringnoone of commitment, policy_snapshot, evidence_bundle, memory_checkpoint, document, output; omitted/empty defaults to output. Anchoring a contract, report, or evidence file? Use document. Closed enum (anything else is 400 invalid_category); one alias is accepted, evidence_manifestevidence_bundle.
labelstringnofree-text tag (display only).
filenamestringnosource filename. Display-only; does not enter the canonical doc's hash.
session_idstringnooff-chain label for grouping. Useful for agent runs.
force_newboolnobypass the same-hash dedup gate. Default false.
Idempotency-Key (header)stringnoa stable per-request key. 24h window, body-hash must match on replay. See §7.

For a commitment anchor the category is the only thing that varies substantively across calls; the shape stays the same.

Standard-mode hashes are publicly discoverable. Anyone who holds (or can reconstruct) the exact file bytes can compute the sha256 and ask the auth-free /lookup_hash endpoint whether this server anchored it — no account needed. If the existence of an anchor is itself sensitive (a source document, a privileged draft), anchor it in sealed mode instead: sealed anchors commit to an HMAC and are excluded from that endpoint by design.

Folder creation (one-time)

A folder is the namespace each proof files under. One per integration is fine.

curl -X POST https://app.satsignal.cloud/api/v1/folders \
  -H "Authorization: Bearer $SATSIGNAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"AcmeCorp Events Dev","slug":"acme-events-dev"}'

New code should use /api/v1/folders; the legacy route remains accepted (Compatibility map).

4. What you store

After a successful anchor you persist these in your own system, alongside the original artifact. Without the original bytes the proof can still be checked against the chain — but it can't be tied back to your data.

Database row shape (reference)

A minimal SQL-ish schema for an integrator's local store:

columntypenotes
proof_idtext PKfrom response
txidtextfrom response
sha256_hextextwhat you submitted
file_sizeintwhat you submitted
folder_slugtextnamespace
labeltextoptional tag
bundle_urltextabsolute URL
artifact_pathtextwhere the original bytes live in your storage
anchored_at_utctimestampresponse accepted_at_utc if you read the manifest

That's it. The on-chain transaction is the source of truth for the proof itself; the row above is your local index into that.

5. What verification needs later

Three things, every time:

  1. The .mbnt bundle. Self-contained — carries manifest.json, canonical.json, optional proofs.json. The portable proof.
  2. The original artifact. The bytes whose SHA-256 was anchored. For a file proof the verifier re-hashes the file and compares to the canonical doc's byte_exact.hash. Without the artifact, the chain side still verifies but you can't tie that on-chain commitment to your specific file.
  3. A way to read a BSV transaction. Any public BSV node — no Satsignal-operated endpoint required, no API key required. The verifier resolves the txid, parses the OP_RETURN payload, and compares the embedded doc_hash to sha256(SCJ_v1(canonical.json))[:20] (SCJ-v1 = sorted keys, compact, NFC — deliberately NOT RFC 8785/JCS).

The web verifier at /verify does all three steps (drag-drop the .mbnt, account-free); for a scriptable check, the unzip + sha256sum recipe in §11 does the same from a shell, resolving the txid against any explorer or a node you run. (A standalone satsignal verify CLI, with --spv / --min-confirmations, is planned but not yet shipped.) Cross-link: What to hash covers what counts as "the bytes you hashed" for each artifact shape — files, JSON events, webhook bodies, agent decisions.

For CI runners, agent runtimes, and other non-browser integrators that can't drive the web verifier: §11 covers the programmatic verify paths (the manual unzip + sha256sum recipe and the auth-free /lookup_hash endpoint), plus the RECEIVED vs CONFIRMED semantics for what to do while public explorers catch up with a freshly-broadcast transaction.

6. Copy-paste example

The runnable five-step shell flow. Replace sk_... with your key from app.satsignal.cloud.

export SATSIGNAL_API_KEY=sk_...
export FOLDER=acme-events-dev

# 1. Create a folder (one-time per project / matter / engagement)
curl -X POST https://app.satsignal.cloud/api/v1/folders \
  -H "Authorization: Bearer $SATSIGNAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"AcmeCorp Events Dev","slug":"acme-events-dev"}'

# 2. Write the artifact you want to prove you held
echo '{"event":"order.completed","id":"evt_demo_123","ts":"2026-05-26T00:00:00Z"}' > event.json

# 3. Hash it locally — the bytes never leave your machine
HASH=$(shasum -a 256 event.json | awk '{print $1}')
SIZE=$(wc -c < event.json | tr -d ' ')

# 4. Anchor the hash. Idempotency-Key makes safe retries free.
curl -X POST https://app.satsignal.cloud/api/v1/anchors \
  -H "Authorization: Bearer $SATSIGNAL_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: demo-evt_demo_123" \
  -d "{
    \"folder_slug\":\"$FOLDER\",
    \"sha256_hex\":\"$HASH\",
    \"file_size\":$SIZE,
    \"category\":\"commitment\",
    \"label\":\"Demo AcmeCorp event\",
    \"filename\":\"event.json\"
  }"

The response:

{
  "proof_id": "abc123def456...",
  "txid": "5e9a...c4f1",
  "mode": "standard",
  "category": "commitment",
  "folder_slug": "acme-events-dev",
  "proof_url": "https://app.satsignal.cloud/w/.../r/abc123def456",
  "bundle_url": "https://app.satsignal.cloud/bundle/abc123def456.mbnt"
}

Then anyone — including someone with no Satsignal account — can verify your proof:

# Download the bundle (requires your key)
curl -H "Authorization: Bearer $SATSIGNAL_API_KEY" \
  https://app.satsignal.cloud/bundle/abc123def456.mbnt -o proof.mbnt

# Verify it — no key needed
# Web: drop proof.mbnt + event.json on https://proof.satsignal.cloud/verify
# Offline shell: python scripts/agent_anchor.py verify-handoff (see §11)

Python variant

Same call, plain urllib.request. Stdlib only. Useful if you're embedding in a notebook or a small backend job.

import hashlib, json, os, urllib.request

API = "https://app.satsignal.cloud"
KEY = os.environ["SATSIGNAL_API_KEY"]
FOLDER = "acme-events-dev"

with open("event.json", "rb") as fh:
    data = fh.read()
sha = hashlib.sha256(data).hexdigest()

body = json.dumps({
    "folder_slug": FOLDER,
    "sha256_hex": sha,
    "file_size": len(data),
    "category": "commitment",
    "label": "Demo AcmeCorp event",
    "filename": "event.json",
}).encode("utf-8")

req = urllib.request.Request(
    f"{API}/api/v1/anchors",
    data=body,
    method="POST",
    headers={
        "Authorization": f"Bearer {KEY}",
        "Content-Type": "application/json",
        "Idempotency-Key": "demo-evt_demo_123",
    },
)
with urllib.request.urlopen(req) as resp:
    out = json.load(resp)

print(out["proof_id"], out["txid"], out["proof_url"])

Conceptual walkthrough — what each step is doing

  1. Get an API key. Sign in at app.satsignal.cloud with a magic link. Mint a key with the anchors:create scope at /w/<workspace>/keys.
  2. Create a folder. Folders are the namespace each proof files under — one per integration is fine. (Legacy request spellings remain accepted — see the Compatibility map.)
  3. Anchor a commitment. Hash your payload locally, then POST /api/v1/anchors with folder_slug, sha256_hex, file_size, category. The response carries proof_id, txid, proof_url.
  4. Download the bundle. The response carries bundle_url — a full URL on app.satsignal.cloud. GET it with bearer auth; the .mbnt zip carries the canonical doc, manifest, miner's signed ARC acceptance.
  5. Verify without us. Drop the bundle (plus the original file) into proof.satsignal.cloud/verify (account-free) — it runs every check locally in your browser. For a scriptable check, the §11 unzip + sha256sum recipe resolves the txid against any public BSV node — no Satsignal account or API key needed at verify time. (A standalone satsignal verify CLI is planned but not yet shipped.)

Running in a webhook handler / CI job / agent runtime where you can't drive a browser? See §11 — the same verification works from agent_anchor.py verify-handoff, a plain unzip + jq pipeline, or the auth-free /lookup_hash endpoint.

7. Production notes

Idempotency

Send Idempotency-Key: <stable-string> on every retry-prone anchor. The notary records the body hash against that key. A replay with the exact same body returns the cached response; a replay with a different body returns 409 idempotency_key_reuse_body_mismatch. Window: 24 hours. After 24 hours the key recycles and the body hash is no longer cached.

Records-before-broadcast: the idempotency record is written before the wallet is touched, so a burst load can return 504 on later requests in the same burst — but a retry with the same key and body gets the original response, not a duplicate anchor. This reframes burst-load 504s from "regression" to "recoverable latency-tail".

Rate limits

The anchor API's only key-level throttle is the plan quota window — there is no separate per-minute or per-hour burst limiter on POST /api/v1/anchors today. Every 2xx and 429 anchor response carries the quota state as headers:

headermeaning
X-RateLimit-Limitanchors allowed in the plan's quota window
X-RateLimit-Remainingremaining in the current window
X-RateLimit-ResetUTC epoch seconds when the window resets
X-RateLimit-Windowmonth (free/starter/pro/scale) or day (legacy trial/paid)

Current window ceilings: Free 100/month, Starter 10,000/month, Pro 100,000/month, Scale custom — see pricing.

A 429 quota_exceeded from the anchor API means the window is exhausted. It carries the same X-RateLimit-* headers but no Retry-After — wait for X-RateLimit-Reset (the window is a fixed calendar window, not a sliding bucket) or mail hello@satsignal.cloud for a cap lift.

Separately, the auth-free /lookup_hash endpoint has its own hour-windowed limits (120/hour per IP anonymous; 5,000/hour per workspace with a bearer key) and its 429 does carry Retry-After.

Retries

Retryable error classes:

Non-retryable:

Key rotation

Mint a new key at /w/<workspace>/keys, swap in your store, revoke the old one. Existing proofs (their proof_id, txid, bundle_url) are not invalidated by key rotation — proofs are bearer artifacts not tied to the key that anchored them. Only future anchor calls and bundle downloads need the new key.

Quota visibility

curl -H "Authorization: Bearer $SATSIGNAL_API_KEY" \
  https://app.satsignal.cloud/api/v1/usage

Returns the current month's count, plan ceiling, and reset epoch. Wire this into your monitoring; surprise quota exhaustion is the single most common cause of "why did anchors stop landing?".

Failed or stuck anchor recovery

If a POST returned a proof_id but you never saw txid materialize (rare — only on a wallet-internal failure), GET /api/v1/anchors/<proof_id> returns the current state. A stuck anchor will eventually re-broadcast on the next sweep cycle (~10 min). If after 30 minutes the proof still has no txid, file a support ticket with the proof_id.

For the full pre-flight checklist (key rotation, broadcast failure recovery, support flow), see Production checklist.

8. Errors you might see

codenamemeaningwhat to do
400bad_requestmalformed JSON, missing field, wrong typefix the body
400unknown_fielda field name the API doesn't recognizedrop it; check API reference for allowed fields
400invalid_categorycategory not in the enumuse one of commitment, policy_snapshot, evidence_bundle, memory_checkpoint, document, output
400invalid_sha256sha256_hex is not 64 lowercase hex charsre-hash; ensure lowercase hex
401unauthorizedmissing / wrong bearercheck the Authorization header
403forbiddenkey lacks the anchors:create scope, or folder is out-of-scope for a scoped keymint a key with the right scope
404folder_not_foundfolder doesn't existcreate the folder first
409proof_set_requires_force_newsame sha256_hex already anchored without proof_set; new request adds a proof_setsend force_new: true
409idempotency_key_reuse_body_mismatchthe Idempotency-Key was used before with a different bodyuse a fresh key
429quota_exceededmonthly anchor count exhaustedemail hello@satsignal.cloud for a cap lift, or wait for the monthly reset
504timeout_no_recordsburst load: records not written within deadlineretry with the same Idempotency-Key

9. Legacy field aliases (if your code sends them)

Responses emit the canonical field names only. Requests written against the legacy spellings (pre-2026-05-18 vocabulary) keep working forever — they are folded into the canonical names at the boundary. The full canonical/legacy mapping across endpoints, fields, scopes, CLI flags, and error codes lives in the Compatibility map.

10. Where this fits

11. Verifying programmatically (without a browser)

The drag-and-drop verifier at proof.satsignal.cloud/verify is the easiest path for a human checking a single bundle, but CI runners, agent runtimes, and webhook consumers can't drive a browser. This section is the non-browser verify path.

What "verified" actually means

A .mbnt bundle binds three artifacts together:

  1. A canonical sha256 over the bundle's canonical.json payload — the content commitment (your file's hash + metadata, in a stable byte order).
  2. A BSV transaction (txid) whose OP_RETURN carries the 20-byte commitment.
  3. A merkle proof linking the canonical sha256 to that transaction.

You can verify each independently:

CheckNeedsWhat it proves
canonical.json sha256 matchesbundle onlyThe bundle is internally consistent.
Canonical contains your file's sha256bundle + original file bytesYou hold the same bytes as the anchorer.
On-chain OP_RETURN payload matchesbundle + a BSV explorerThe commitment was actually broadcast.
Transaction is in a confirmed blockbundle + chain readerThe proof is "chain-confirmed".

If you do not have the original file bytes, you can still verify the bundle is self-consistent and that the OP_RETURN commitment was broadcast. What you cannot do is prove the bundle's canonical.json matches some specific unknown external file — that binding is a property of you-the-holder, not the bundle.

Path A — the hosted verifier (no install)

Drop the .mbnt (plus the original artifact) at proof.satsignal.cloud/verify — account-free; it runs the full bundle-integrity + chain-confirm check locally in your browser. Right for a one-off check when you have the file in front of you.

A standalone satsignal verify CLI (with --spv / --min-confirmations) that does this in one shell command is planned but not yet shipped — there is no pip install satsignal-cli verify package today. Until it ships, use the hosted verifier above, the unzip + sha256sum recipe in Path B, or /lookup_hash (Path C). (Verifying an agent-session handoff.json packet — a bundle of many anchors, not a single .mbnt — is a different job: python scripts/agent_anchor.py verify-handoff chain-confirms the session; see the agents guide.)

CI tip — fresh anchors and the explorer-indexing window. Whichever verify path you script, an anchor only resolves on-chain once a public block explorer has indexed the txid. During the explorer-indexing window right after broadcast (typically 1–5 minutes, occasionally longer — ~10) the on-chain lookup can 404 regardless of the bundle being valid, so have CI wait + retry the verify rather than fail the build. The bundle itself is fully verifiable offline in the meantime (Path B); only the chain-resolution step needs the explorer.

Path B — inspect the .mbnt manually

A .mbnt bundle is a zip with two files (manifest.json and canonical.json). For a Bash + jq integrator:

unzip -q proof.mbnt -d /tmp/bundle-extract/

# 1. Bundle's claimed file hash matches your local file?
# Path depends on canonical.json's schema_version:
#   v2 → subject.proofs.byte_exact.hash
#   v1 → subject.document_sha256   (older bundles emitted by some flows)
jq -r 'if .schema_version >= 2 then .subject.proofs.byte_exact.hash else .subject.document_sha256 end' /tmp/bundle-extract/canonical.json
sha256sum event.json
# both should print the same 64-hex string

# 2. Pull the on-chain commitment.
jq -r '.txid' /tmp/bundle-extract/manifest.json
# -> bc1a35b4c3e5ba8e62c087dfef2f51927677914716d84b1bc3e9d2201218594b

# Optional: broadcast acceptance record (informational). manifest.json
# carries an .acceptance block ONLY when the broadcaster returned a
# network-validated status (SEEN_ON_NETWORK, ACCEPTED_BY_NETWORK, ...).
# Unsigned strings; not load-bearing for verification. Freshly minted
# bundles typically omit it — absence is normal, not a failure.
jq -r '.acceptance.status // "no acceptance block (normal)"' /tmp/bundle-extract/manifest.json

If the claimed file hash from step 1 equals the sha256 of your file, and you can look up the txid on any BSV explorer (whatsonchain.com, bitails.io, your own node), you have everything the planned satsignal verify CLI would check minus the convenience of one command.

manifest.json, field by field. Every standard bundle carries txid, network, and doc_hash_expected (first 20 bytes of sha256(canonical.json), hex-encoded). Standard bundles minted before 2026-06-11 also carry proof_mode — a legacy dispatch hint whose value is always private_receipt; newer standard bundles omit it. The name predates sealed mode and is not a privacy indicator; sealed bundles never carried it and use mode: "sealed" (plus salt_b64 / salt_version) instead. Verifiers must not switch on proof_mode — dispatch on mode — and must tolerate both its presence (older bundles, forever) and its absence. Optional fields: category (present only when non-default) and acceptance (above). There is no broadcaster field and no confirmations field anywhere in the bundle — the manifest is a static file written once at anchor time; confirmation depth lives on the public chain. Full field table: /spec-bundle §3.

Path C — /lookup_hash for hash-existence (auth-free)

If all you want is "has this exact file ever been anchored on this server?", use the public lookup endpoint — no key, no bundle:

SHA=$(sha256sum event.json | awk '{print $1}')
curl -sS "https://app.satsignal.cloud/lookup_hash?sha=$SHA"
# Hit:  {"proof_id":"...","created_utc":"...","txid":"..."}
# Miss: {"miss":true,"reason":"sha_not_indexed_as_file_hash"}

miss on an anchor you know exists? If the anchor was sealed (mode=sealed), /lookup_hash will always return miss with reason: sha_not_indexed_as_file_hash. Sealed anchors commit to an HMAC of the document, not the file sha, so they're intentionally excluded from this endpoint. Use the /verify browser UI (which takes the document + salt) to confirm a sealed anchor, or see the sealed integration guide for the full verification flow.

The inverse cuts too: Standard-mode hashes are publicly discoverable. Anyone who holds (or can reconstruct) the exact file bytes can compute the sha256 and ask /lookup_hash whether this server anchored it — no account needed. If the existence of an anchor is itself sensitive, anchor in sealed mode: sealed anchors commit to an HMAC and are excluded from this endpoint by design.

The query parameter accepts both ?sha=<hex> (the historical canonical) and ?sha256_hex=<hex> (an alias matching the JSON request-body field name elsewhere). Either alone returns the same response. If both are supplied they must agree — different values return 400 conflicting_alias, the same convention /api/v1/anchors uses for folder_slug vs its legacy alias. See the Compatibility map for the full alias table.

/lookup_hash is rate-limited per IP (120/hour by default) and is intentionally CORS-open — third-party verifier UIs can call from any origin without proxying.

RECEIVED vs CONFIRMED — and what to do when explorers lag

Right after POST /api/v1/anchors returns, the transaction has been accepted by the BSV broadcaster (3-tier failover across independent broadcast services) — the broadcast-lifecycle state broadcasters call RECEIVED. That state is not a manifest field: the bundle's manifest.json is a static file written once at anchor time and never updates. Broadcast acceptance also does not yet mean a public explorer has indexed the tx. Indexers like WhatsOnChain typically pick it up within 1–5 minutes of broadcast, occasionally longer (~10 minutes). During that window:

CONFIRMED means a block has been mined containing the transaction. On BSV this is typically within ~10 minutes of broadcast under normal load. Confirmation depth is a property of the public chain, not of the bundle — poll any explorer for it, and treat RECEIVED as a pending state rather than a failure. A CI gate that waits for depth ≥ 6:

TXID=$(jq -r '.txid' /tmp/bundle-extract/manifest.json)
until [ "$(curl -s "https://api.whatsonchain.com/v1/bsv/main/tx/hash/$TXID" \
           | jq -r '.confirmations // 0')" -ge 6 ]; do sleep 60; done

Quick decision table

Your situationUse
One-off verify, you have the filethe hosted verifier (Path A)
CI step, file is in the job workspaceunzip + sha256sum (Path B)
Webhook consumer, just confirming "did this hash anchor?"/lookup_hash (Path C)
Agent runtime, no Python at handunzip + jq (Path B)
Auditor with a .mbnt but no original filePath B — verify internal + on-chain consistency only
Worried explorer 404 means the proof failedDon't be — see § RECEIVED vs CONFIRMED above

Questions about this specification? Email hello@satsignal.cloud.